Ver a proveniência

fix(kling): 修复原生路由和展示价格保存

- 避免仅编辑展示价格时写入默认真实计费规则
- 将 video-extend 纳入 KlingAiping 原生渠道约束
- 补充相关保存决策、渠道约束和计费展示测试

Co-Authored-By: Codex <noreply@anthropic.com>
master
fengsilin há 4 dias
ascendente
cometimento
49ca8a6ae7
28 ficheiros alterados com 1455 adições e 607 eliminações
  1. +6
    -0
      .gitignore
  2. +59
    -27
      controller/kling_aiping_native.go
  3. +8
    -18
      controller/kling_aiping_native_test.go
  4. +4
    -3
      controller/model_pricing_controller.go
  5. +21
    -3
      controller/model_pricing_controller_test.go
  6. +38
    -0
      controller/pricing_user_test.go
  7. +22
    -13
      controller/relay.go
  8. +21
    -1
      relay/channel/task/kling/aiping/adaptor.go
  9. +48
    -10
      relay/channel/task/kling/aiping/adaptor_test.go
  10. +41
    -43
      relay/channel/task/kling/aiping/routes.go
  11. +3
    -3
      router/api-router.go
  12. +13
    -25
      router/video-router.go
  13. +6
    -6
      router/video_router_test.go
  14. +1
    -1
      scripts/deploy-test.sh
  15. +102
    -0
      service/kling_asset_channel.go
  16. +64
    -0
      service/kling_asset_channel_test.go
  17. +142
    -0
      service/task_billing_test.go
  18. +42
    -33
      service/task_polling.go
  19. +33
    -5
      web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx
  20. +102
    -0
      web/src/components/table/usage-logs/UsageLogExpandedDetailData.js
  21. +69
    -0
      web/src/components/table/usage-logs/UsageLogExpandedDetailData.test.js
  22. +28
    -2
      web/src/hooks/usage-logs/useUsageLogsData.jsx
  23. +0
    -277
      web/src/pages/Setting/Operation/ModelPricing/DisplayPricing.jsx
  24. +149
    -0
      web/src/pages/Setting/Operation/ModelPricing/DisplayPricingForm.jsx
  25. +161
    -88
      web/src/pages/Setting/Operation/ModelPricing/Editor.jsx
  26. +83
    -0
      web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.js
  27. +128
    -0
      web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.test.js
  28. +61
    -49
      web/src/pages/Setting/Operation/ModelPricing/index.jsx

+ 6
- 0
.gitignore Ver ficheiro

@@ -28,6 +28,12 @@ CLAUDE.md
logs/
docs/superpowers

# Runtime request/response capture files
/[0-9]*.json

# Local dev server logs
web/vite-*.log

# E2E / Playwright artifacts
test-artifacts/
web/.playwright/


+ 59
- 27
controller/kling_aiping_native.go Ver ficheiro

@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
@@ -41,9 +42,32 @@ func KlingAipingNativeTaskSubmit(c *gin.Context) {

configureKlingAipingTaskRelayInfo(c, relayInfo, route, payload)

if bound := resolveKlingAipingBoundChannel(c, relayInfo); bound != nil {
relayInfo.LockedChannel = bound
}

relayTaskWithInfo(c, relayInfo)
}

func resolveKlingAipingBoundChannel(c *gin.Context, relayInfo *relaycommon.RelayInfo) *model.Channel {
group := concreteTaskVideoBindingGroup(c, relayInfo.TokenGroup)
if group == "" {
return nil
}
channel, err := service.ResolveKlingAssetChannel(c.GetInt("id"), group)
if err != nil {
logger.LogError(c, fmt.Sprintf("resolve kling aiping bound channel failed: %v", err))
return nil
}
if channel == nil || !service.IsUsableKlingAssetChannel(channel, group) {
return nil
}
if !model.IsChannelEnabledForGroupModel(group, relayInfo.OriginModelName, channel.Id) {
return nil
}
return channel
}

func configureKlingAipingTaskRelayInfo(c *gin.Context, relayInfo *relaycommon.RelayInfo, route klingaiping.Route, payload map[string]any) {
// Native Kling routes bypass Distribute(), so force relayTaskWithInfo to
// select a channel instead of reading a preselected one from context.
@@ -108,16 +132,21 @@ func KlingAipingNativeProxy(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "route not found"})
return
}
channel, _, err := service.CacheGetRandomSatisfiedChannel(&service.RetryParam{
Ctx: c,
TokenGroup: common.GetContextKeyString(c, constant.ContextKeyUsingGroup),
ModelName: route.BillingModel,
Retry: common.GetPointer(0),
RequiredChannelType: constant.ChannelTypeKlingAiping,
})
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "message": err.Error()})
return
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
channel := resolveKlingAipingBoundChannelForProxy(c, group, route.BillingModel)
if channel == nil {
var err error
channel, _, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{
Ctx: c,
TokenGroup: group,
ModelName: route.BillingModel,
Retry: common.GetPointer(0),
RequiredChannelType: constant.ChannelTypeKlingAiping,
})
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "message": err.Error()})
return
}
}
if setupErr := middleware.SetupContextForSelectedChannel(c, channel, route.BillingModel); setupErr != nil {
c.JSON(setupErr.StatusCode, gin.H{"code": setupErr.GetErrorCode(), "message": setupErr.Error()})
@@ -133,6 +162,25 @@ func KlingAipingNativeProxy(c *gin.Context) {
copyProxyResponse(c, resp)
}

func resolveKlingAipingBoundChannelForProxy(c *gin.Context, group, billingModel string) *model.Channel {
group = strings.TrimSpace(group)
if group == "" || group == "auto" || strings.TrimSpace(billingModel) == "" {
return nil
}
channel, err := service.ResolveKlingAssetChannel(c.GetInt("id"), group)
if err != nil {
logger.LogError(c, fmt.Sprintf("resolve kling aiping bound channel (proxy) failed: %v", err))
return nil
}
if channel == nil || !service.IsUsableKlingAssetChannel(channel, group) {
return nil
}
if !model.IsChannelEnabledForGroupModel(group, billingModel, channel.Id) {
return nil
}
return channel
}

func readJSONPayload(c *gin.Context) (map[string]any, error) {
body, err := common.GetBodyStorage(c)
if err != nil {
@@ -358,23 +406,7 @@ func proxyBodyBytes(c *gin.Context, route klingaiping.Route) ([]byte, error) {
if err != nil {
return nil, err
}
data, err := storage.Bytes()
if err != nil {
return nil, err
}
if route.PathPattern == "/v1/elements/delete" {
payload := map[string]any{}
if err := common.Unmarshal(data, &payload); err == nil {
if _, ok := payload["element_id"]; !ok {
if id := stringFromMap(payload, "id"); id != "" {
payload["element_id"] = id
delete(payload, "id")
data, _ = common.Marshal(payload)
}
}
}
}
return data, nil
return storage.Bytes()
}

func copyProxyResponse(c *gin.Context, resp *http.Response) {


+ 8
- 18
controller/kling_aiping_native_test.go Ver ficheiro

@@ -45,13 +45,13 @@ func TestKlingAipingSubmitPreparationUsesModelNameBeforeModel(t *testing.T) {
}

func TestKlingAipingSubmitPreparationDoesNotLockChannel(t *testing.T) {
c := newControllerJSONContext(t, "/v1/kling/general/custom-voices", `{
c := newControllerJSONContext(t, "/v1/general/custom-voices", `{
"voice_url":"https://example.com/voice.mp3",
"voice_name":"voice"
}`)
payload, err := readJSONPayload(c)
require.NoError(t, err)
route, ok := klingaiping.FindRoute(http.MethodPost, "/v1/kling/general/custom-voices", klingaiping.RouteKindSubmit)
route, ok := klingaiping.FindRoute(http.MethodPost, "/v1/general/custom-voices", klingaiping.RouteKindSubmit)
require.True(t, ok)
info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
info.OriginModelName = resolveKlingAipingModel(payload, route)
@@ -88,6 +88,10 @@ func TestRequiredTaskChannelTypeOnlyMatchesKlingAipingNativePaths(t *testing.T)
c.Request.URL.Path = "/v1/videos/text2video"
require.Equal(t, constant.ChannelTypeKlingAiping, requiredTaskChannelTypeForRequest(c))

c = newControllerJSONContext(t, "/v1/videos/video-extend", `{}`)
c.Request.URL.Path = "/v1/videos/video-extend"
require.Equal(t, constant.ChannelTypeKlingAiping, requiredTaskChannelTypeForRequest(c))

c = newControllerJSONContext(t, "/v1/videos/video_123/remix", `{}`)
c.Request.URL.Path = "/v1/videos/video_123/remix"
require.Zero(t, requiredTaskChannelTypeForRequest(c))
@@ -117,20 +121,6 @@ func TestKlingAipingTaskDataObjectUsesPublicTaskIDAndWatermarkURL(t *testing.T)
require.Equal(t, "", videos[0].(map[string]any)["watermark_url"])
}

func TestProxyBodyBytesMapsElementDeleteIDToElementID(t *testing.T) {
c := newControllerJSONContext(t, "/v1/elements/delete", `{"id":"elem_1"}`)
route, ok := klingaiping.FindRoute(http.MethodPost, "/v1/elements/delete", klingaiping.RouteKindProxy)
require.True(t, ok)

data, err := proxyBodyBytes(c, route)

require.NoError(t, err)
var payload map[string]any
require.NoError(t, common.Unmarshal(data, &payload))
require.Equal(t, "elem_1", payload["element_id"])
require.NotContains(t, payload, "id")
}

func TestDoKlingAipingProxyRequestUsesSelectedContextKey(t *testing.T) {
service.InitHttpClient()
var gotAuth string
@@ -141,9 +131,9 @@ func TestDoKlingAipingProxyRequestUsesSelectedContextKey(t *testing.T) {
}))
defer server.Close()

c := newControllerJSONContext(t, "/v1/elements/custom", ``)
c := newControllerJSONContext(t, "/v1/general/advanced-presets-elements", ``)
common.SetContextKey(c, constant.ContextKeyChannelKey, "selected-key")
route, ok := klingaiping.FindRoute(http.MethodGet, "/v1/elements/custom", klingaiping.RouteKindProxy)
route, ok := klingaiping.FindRoute(http.MethodGet, "/v1/general/advanced-presets-elements", klingaiping.RouteKindProxy)
require.True(t, ok)
channel := &model.Channel{
Key: "raw-channel-key",


+ 4
- 3
controller/model_pricing_controller.go Ver ficheiro

@@ -2,6 +2,7 @@ package controller

import (
"net/http"
"strings"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
@@ -15,7 +16,7 @@ func GetModelPricingRules(c *gin.Context) {
}

func GetModelPricingRule(c *gin.Context) {
modelName := c.Param("model")
modelName := strings.TrimPrefix(c.Param("model"), "/")
cfg := ratio_setting.GetPricingConfig(modelName)
if cfg == nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "model pricing rule not found"})
@@ -25,7 +26,7 @@ func GetModelPricingRule(c *gin.Context) {
}

func UpdateModelPricingRule(c *gin.Context) {
modelName := c.Param("model")
modelName := strings.TrimPrefix(c.Param("model"), "/")
var cfg types.PricingConfig
if err := common.DecodeJson(c.Request.Body, &cfg); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
@@ -44,7 +45,7 @@ func UpdateModelPricingRule(c *gin.Context) {
}

func DeleteModelPricingRule(c *gin.Context) {
modelName := c.Param("model")
modelName := strings.TrimPrefix(c.Param("model"), "/")
if err := ratio_setting.DeletePricingConfig(modelName); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return


+ 21
- 3
controller/model_pricing_controller_test.go Ver ficheiro

@@ -49,9 +49,9 @@ func setupModelPricingControllerTest(t *testing.T) *gin.Engine {

r := gin.New()
r.GET("/api/option/model_pricing", GetModelPricingRules)
r.GET("/api/option/model_pricing/:model", GetModelPricingRule)
r.PUT("/api/option/model_pricing/:model", UpdateModelPricingRule)
r.DELETE("/api/option/model_pricing/:model", DeleteModelPricingRule)
r.GET("/api/option/model_pricing/*model", GetModelPricingRule)
r.PUT("/api/option/model_pricing/*model", UpdateModelPricingRule)
r.DELETE("/api/option/model_pricing/*model", DeleteModelPricingRule)
return r
}

@@ -94,6 +94,24 @@ func TestModelPricingController_PutRejectsBadConfig(t *testing.T) {
assert.Contains(t, w.Body.String(), "table must not be empty")
}

func TestModelPricingController_CRUDWithSlashModelName(t *testing.T) {
r := setupModelPricingControllerTest(t)
cfg := validControllerPricingConfig()
modelName := "openai/gpt-4o"

w := performJSONRequest(t, r, http.MethodPut, "/api/option/model_pricing/"+modelName, cfg)
require.Equal(t, http.StatusOK, w.Code)
require.NotNil(t, ratio_setting.GetPricingConfig(modelName))

w = performJSONRequest(t, r, http.MethodGet, "/api/option/model_pricing/"+modelName, nil)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), `"billing_unit":"per_call"`)

w = performJSONRequest(t, r, http.MethodDelete, "/api/option/model_pricing/"+modelName, nil)
require.Equal(t, http.StatusOK, w.Code)
assert.Nil(t, ratio_setting.GetPricingConfig(modelName))
}

func performJSONRequest(t *testing.T, r *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var payload []byte


+ 38
- 0
controller/pricing_user_test.go Ver ficheiro

@@ -183,6 +183,44 @@ func TestGetUserPricingIncludesDisplayPricingWithoutChangingComputedPrice(t *tes
require.NotEqual(t, float64(0.33333), resp["original_price"])
}


func TestGetPricingIncludesDisplayPricingForModelSquare(t *testing.T) {
setupPricingTestDB(t)
modelName := "display-pricing-square-model"
router := gin.New()
router.GET("/api/pricing", GetPricing)

setPricingCache([]model.Pricing{
{
ModelName: modelName,
QuotaType: model.QuotaTypeByCall,
ModelPrice: 0.5,
EnableGroup: []string{"default"},
DisplayPricing: []types.ModelDisplayPricingItem{
{Specification: "768p-6s", Price: 0.33333, Unit: "second", SortOrder: 1, DiscountRate: 1},
},
},
})
withGroupRatio(t, `{"default":1}`)

w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/pricing", nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, true, resp["success"])
data := resp["data"].([]interface{})
require.Len(t, data, 1)
item := data[0].(map[string]interface{})
require.Equal(t, modelName, item["model_name"])
require.Contains(t, item, "display_pricing")
displayPricing := item["display_pricing"].([]interface{})
require.Len(t, displayPricing, 1)
require.Equal(t, "768p-6s", displayPricing[0].(map[string]interface{})["specification"])
}

// TestGetUserPricing_LoggedIn_NoDiscount 已登录但无折扣(分组倍率=1,无个人倍率)
func TestGetUserPricing_LoggedIn_NoDiscount(t *testing.T) {
db := setupPricingTestDB(t)


+ 22
- 13
controller/relay.go Ver ficheiro

@@ -296,7 +296,7 @@ func fastTokenCountMetaForPricing(request dto.Request) *types.TokenCountMeta {
return meta
}

func concreteDoubaoVideoBindingGroup(c *gin.Context, group string) string {
func concreteTaskVideoBindingGroup(c *gin.Context, group string) string {
group = strings.TrimSpace(group)
if group == "" {
group = strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyTokenGroup))
@@ -311,15 +311,24 @@ func concreteDoubaoVideoBindingGroup(c *gin.Context, group string) string {
return group
}

func persistDoubaoVideoTaskBindingIfNeeded(userId int, group string, channel *model.Channel) error {
func persistTaskVideoBindingIfNeeded(userId int, group string, channel *model.Channel) error {
group = strings.TrimSpace(group)
if group == "" || group == "auto" {
if group == "" || group == "auto" || channel == nil {
return nil
}
if !service.IsUsableDoubaoAssetChannel(channel, group) {
return nil
switch channel.Type {
case service.DoubaoAssetChannelType:
if !service.IsUsableDoubaoAssetChannel(channel, group) {
return nil
}
return model.BindUserAssetChannel(userId, service.DoubaoAssetChannelType, group, channel.Id)
case constant.ChannelTypeKlingAiping:
if !service.IsUsableKlingAssetChannel(channel, group) {
return nil
}
return model.BindUserAssetChannel(userId, constant.ChannelTypeKlingAiping, group, channel.Id)
}
return model.BindUserAssetChannel(userId, service.DoubaoAssetChannelType, group, channel.Id)
return nil
}

func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam) (*model.Channel, string, *types.NewAPIError) {
@@ -334,7 +343,7 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
Type: c.GetInt("channel_type"),
Name: c.GetString("channel_name"),
AutoBan: &autoBanInt,
}, concreteDoubaoVideoBindingGroup(c, info.TokenGroup), nil
}, concreteTaskVideoBindingGroup(c, info.TokenGroup), nil
}
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam)

@@ -668,7 +677,7 @@ func relayTaskWithInfo(c *gin.Context, relayInfo *relaycommon.RelayInfo) {

if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil {
channel = lockedCh
selectedGroup = concreteDoubaoVideoBindingGroup(c, relayInfo.TokenGroup)
selectedGroup = concreteTaskVideoBindingGroup(c, relayInfo.TokenGroup)
if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil {
taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError)
break
@@ -683,8 +692,8 @@ func relayTaskWithInfo(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
}
}

if bindErr := persistDoubaoVideoTaskBindingIfNeeded(c.GetInt("id"), selectedGroup, channel); bindErr != nil {
taskErr = service.TaskErrorWrapperLocal(bindErr, "bind_doubao_video_channel_failed", http.StatusServiceUnavailable)
if bindErr := persistTaskVideoBindingIfNeeded(c.GetInt("id"), selectedGroup, channel); bindErr != nil {
taskErr = service.TaskErrorWrapperLocal(bindErr, "bind_task_video_channel_failed", http.StatusServiceUnavailable)
break
}

@@ -768,9 +777,9 @@ func isKlingAipingNativePath(path string) bool {
"/v1/videos/motion-control",
"/v1/videos/omni-video",
"/v1/videos/multi-image2video",
"/v1/videos/multi-elements",
"/v1/kling/general/advanced-custom-elements",
"/v1/kling/general/custom-voices",
"/v1/videos/video-extend",
"/v1/general/advanced-custom-elements",
"/v1/general/custom-voices",
)
}



+ 21
- 1
relay/channel/task/kling/aiping/adaptor.go Ver ficheiro

@@ -216,7 +216,7 @@ func normalizeRequestBody(body map[string]any, info *relaycommon.RelayInfo) {
modelName = info.UpstreamModelName
}
if modelName != "" {
body["model_name"] = modelName
body["model_name"] = canonicalAipingModelName(modelName)
}
delete(body, "model")

@@ -316,6 +316,26 @@ func stringValue(v any) string {
return ""
}

func canonicalAipingModelName(modelName string) string {
trimmed := strings.TrimSpace(modelName)
switch strings.ToLower(trimmed) {
case "kling-v1":
return "Kling-V1"
case "kling-v1-6":
return "Kling-V1.6"
case "kling-v2-6":
return "Kling-V2.6"
case "kling-v3":
return "Kling-V3"
case "kling-video-o1":
return "Kling-Video-O1"
case "kling-v3-omni":
return "Kling-V3-Omni"
default:
return trimmed
}
}

func firstString(m map[string]any, keys ...string) string {
for _, key := range keys {
if s := stringValue(m[key]); s != "" {


+ 48
- 10
relay/channel/task/kling/aiping/adaptor_test.go Ver ficheiro

@@ -17,29 +17,38 @@ func TestRoutesUseOfficialExternalAndAipingUpstreamPaths(t *testing.T) {
route, ok := FindRoute(http.MethodPost, "/v1/videos/text2video", RouteKindSubmit)
require.True(t, ok)
require.Equal(t, ActionText2Video, route.Action)
require.Equal(t, "/v1/videos/kling/text2video", route.UpstreamPath)
require.Equal(t, "/api/v1/multimodal/kling/videos/text2video", route.UpstreamPath)

voice, ok := FindRoute(http.MethodPost, "/v1/kling/general/custom-voices", RouteKindSubmit)
voice, ok := FindRoute(http.MethodPost, "/v1/general/custom-voices", RouteKindSubmit)
require.True(t, ok)
require.Equal(t, "/v1/kling/general/custom-voices", voice.UpstreamPath)
require.Equal(t, "/api/v1/multimodal/kling/general/custom-voices", voice.UpstreamPath)

presets, ok := FindRoute(http.MethodGet, "/v1/kling/general/presets-voices", RouteKindProxy)
presets, ok := FindRoute(http.MethodGet, "/v1/general/presets-voices", RouteKindProxy)
require.True(t, ok)
require.Equal(t, "/v1/kling/general/presets-voices", presets.UpstreamPath)
require.Equal(t, "/api/v1/multimodal/kling/general/presets-voices", presets.UpstreamPath)

deleteVoices, ok := FindRoute(http.MethodPost, "/v1/kling/general/delete-voices", RouteKindProxy)
deleteVoices, ok := FindRoute(http.MethodPost, "/v1/general/delete-voices", RouteKindProxy)
require.True(t, ok)
require.Equal(t, ActionDeleteVoices, deleteVoices.Action)

deleteElements, ok := FindRoute(http.MethodPost, "/v1/kling/general/delete-elements", RouteKindProxy)
deleteElements, ok := FindRoute(http.MethodPost, "/v1/general/delete-advanced-elements", RouteKindProxy)
require.True(t, ok)
require.Equal(t, ActionDeleteElements, deleteElements.Action)

videoExtend, ok := FindRoute(http.MethodPost, "/v1/videos/video-extend", RouteKindSubmit)
require.True(t, ok)
require.Equal(t, ActionVideoExtend, videoExtend.Action)
require.Equal(t, "/api/v1/multimodal/kling/videos/video-extend", videoExtend.UpstreamPath)

motionControl, ok := FindRoute(http.MethodPost, "/v1/videos/motion-control", RouteKindSubmit)
require.True(t, ok)
require.Equal(t, "", motionControl.BillingModel, "motion-control BillingModel should be empty so client model_name is used")
}

func TestBuildRequestURLUsesAipingKlingPath(t *testing.T) {
adaptor := &TaskAdaptor{}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://aiping.cn/api"},
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://aiping.cn"},
TaskRelayInfo: &relaycommon.TaskRelayInfo{
Action: ActionText2Video,
},
@@ -48,7 +57,7 @@ func TestBuildRequestURLUsesAipingKlingPath(t *testing.T) {

url, err := adaptor.BuildRequestURL(info)
require.NoError(t, err)
require.Equal(t, "https://aiping.cn/api/v1/videos/kling/text2video", url)
require.Equal(t, "https://aiping.cn/api/v1/multimodal/kling/videos/text2video", url)
}

func TestFetchTaskUsesSingleTaskPath(t *testing.T) {
@@ -68,7 +77,7 @@ func TestFetchTaskUsesSingleTaskPath(t *testing.T) {
require.NoError(t, err)
defer resp.Body.Close()

require.Equal(t, "/v1/videos/kling/text2video/upstream-task", gotPath)
require.Equal(t, "/api/v1/multimodal/kling/videos/text2video/upstream-task", gotPath)
}

func TestBuildRequestBodyNormalizesOfficialCompatibleFields(t *testing.T) {
@@ -124,6 +133,35 @@ func TestBuildRequestBodyNormalizesOfficialCompatibleFields(t *testing.T) {
}, got["image_list"])
}

func TestBuildRequestBodyMapsCanonicalModelNameForAiping(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(nil)
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{},
TaskRelayInfo: &relaycommon.TaskRelayInfo{
Action: ActionText2Video,
},
OriginModelName: "kling-v2-6",
}
relaycommon.StoreTaskRequest(c, info, ActionText2Video, relaycommon.TaskSubmitReq{
Model: "kling-v2-6",
Metadata: map[string]any{
"model_name": "kling-v2-6",
"prompt": "prompt",
"duration": "5",
},
})

body, err := (&TaskAdaptor{}).BuildRequestBody(c, info)
require.NoError(t, err)
data, err := io.ReadAll(body)
require.NoError(t, err)

var got map[string]any
require.NoError(t, common.Unmarshal(data, &got))
require.Equal(t, "Kling-V2.6", got["model_name"])
}

func TestValidateRequestRejectsOmniModelOnTextOrImageRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(nil)


+ 41
- 43
relay/channel/task/kling/aiping/routes.go Ver ficheiro

@@ -8,7 +8,7 @@ const (
ActionMotionControl = "kling-motion-control"
ActionOmniVideo = "kling-omni-video"
ActionMultiImage2Video = "kling-multi-image2video"
ActionMultiElements = "kling-multi-elements"
ActionVideoExtend = "kling-video-extend"
ActionAdvancedElements = "kling-advanced-elements-create"
ActionVoicesCreate = "kling-voices-create"
ActionDeleteElements = "kling-delete-elements"
@@ -23,7 +23,6 @@ const (
)

const (
ModelKlingElements = "kling-elements"
ModelKlingAdvancedElements = "kling-advanced-elements"
ModelCustomVoices = "custom-voices"
)
@@ -38,48 +37,48 @@ type Route struct {
}

var Routes = []Route{
{http.MethodPost, "/v1/videos/text2video", ActionText2Video, "/v1/videos/kling/text2video", RouteKindSubmit, ""},
{http.MethodGet, "/v1/videos/text2video/:task_id", ActionText2Video, "/v1/videos/kling/text2video/:task_id", RouteKindFetch, ""},
{http.MethodGet, "/v1/videos/text2video", ActionText2Video, "/v1/videos/kling/text2video", RouteKindList, ""},
{http.MethodPost, "/v1/videos/image2video", ActionImage2Video, "/v1/videos/kling/image2video", RouteKindSubmit, ""},
{http.MethodGet, "/v1/videos/image2video/:task_id", ActionImage2Video, "/v1/videos/kling/image2video/:task_id", RouteKindFetch, ""},
{http.MethodGet, "/v1/videos/image2video", ActionImage2Video, "/v1/videos/kling/image2video", RouteKindList, ""},
{http.MethodPost, "/v1/videos/motion-control", ActionMotionControl, "/v1/videos/kling/motion-control", RouteKindSubmit, "kling-v2-6"},
{http.MethodGet, "/v1/videos/motion-control/:task_id", ActionMotionControl, "/v1/videos/kling/motion-control/:task_id", RouteKindFetch, "kling-v2-6"},
{http.MethodGet, "/v1/videos/motion-control", ActionMotionControl, "/v1/videos/kling/motion-control", RouteKindList, "kling-v2-6"},
{http.MethodPost, "/v1/videos/omni-video", ActionOmniVideo, "/v1/videos/kling/omni-video", RouteKindSubmit, ""},
{http.MethodGet, "/v1/videos/omni-video/:task_id", ActionOmniVideo, "/v1/videos/kling/omni-video/:task_id", RouteKindFetch, ""},
{http.MethodGet, "/v1/videos/omni-video", ActionOmniVideo, "/v1/videos/kling/omni-video", RouteKindList, ""},
{http.MethodPost, "/v1/videos/multi-image2video", ActionMultiImage2Video, "/v1/videos/kling/multi-image2video", RouteKindSubmit, ""},
{http.MethodGet, "/v1/videos/multi-image2video/:task_id", ActionMultiImage2Video, "/v1/videos/kling/multi-image2video/:task_id", RouteKindFetch, ""},
{http.MethodGet, "/v1/videos/multi-image2video", ActionMultiImage2Video, "/v1/videos/kling/multi-image2video", RouteKindList, ""},
{http.MethodPost, "/v1/videos/multi-elements", ActionMultiElements, "/v1/videos/kling/multi-elements", RouteKindSubmit, "kling-v1-6"},
{http.MethodGet, "/v1/videos/multi-elements/:task_id", ActionMultiElements, "/v1/videos/kling/multi-elements/:task_id", RouteKindFetch, "kling-v1-6"},
{http.MethodGet, "/v1/videos/multi-elements", ActionMultiElements, "/v1/videos/kling/multi-elements", RouteKindList, "kling-v1-6"},
// 视频能力:文生视频
{http.MethodPost, "/v1/videos/text2video", ActionText2Video, "/api/v1/multimodal/kling/videos/text2video", RouteKindSubmit, ""},
{http.MethodGet, "/v1/videos/text2video/:task_id", ActionText2Video, "/api/v1/multimodal/kling/videos/text2video/:task_id", RouteKindFetch, ""},
{http.MethodGet, "/v1/videos/text2video", ActionText2Video, "/api/v1/multimodal/kling/videos/text2video", RouteKindList, ""},
// 视频能力:图生视频
{http.MethodPost, "/v1/videos/image2video", ActionImage2Video, "/api/v1/multimodal/kling/videos/image2video", RouteKindSubmit, ""},
{http.MethodGet, "/v1/videos/image2video/:task_id", ActionImage2Video, "/api/v1/multimodal/kling/videos/image2video/:task_id", RouteKindFetch, ""},
{http.MethodGet, "/v1/videos/image2video", ActionImage2Video, "/api/v1/multimodal/kling/videos/image2video", RouteKindList, ""},
// 视频能力:动作控制(model_name 可指定 Kling-V3 / Kling-V2.6)
{http.MethodPost, "/v1/videos/motion-control", ActionMotionControl, "/api/v1/multimodal/kling/videos/motion-control", RouteKindSubmit, ""},
{http.MethodGet, "/v1/videos/motion-control/:task_id", ActionMotionControl, "/api/v1/multimodal/kling/videos/motion-control/:task_id", RouteKindFetch, ""},
{http.MethodGet, "/v1/videos/motion-control", ActionMotionControl, "/api/v1/multimodal/kling/videos/motion-control", RouteKindList, ""},
// 视频能力:Omni / 多镜头
{http.MethodPost, "/v1/videos/omni-video", ActionOmniVideo, "/api/v1/multimodal/kling/videos/omni-video", RouteKindSubmit, ""},
{http.MethodGet, "/v1/videos/omni-video/:task_id", ActionOmniVideo, "/api/v1/multimodal/kling/videos/omni-video/:task_id", RouteKindFetch, ""},
{http.MethodGet, "/v1/videos/omni-video", ActionOmniVideo, "/api/v1/multimodal/kling/videos/omni-video", RouteKindList, ""},
// 视频能力:多图参考生视频
{http.MethodPost, "/v1/videos/multi-image2video", ActionMultiImage2Video, "/api/v1/multimodal/kling/videos/multi-image2video", RouteKindSubmit, ""},
{http.MethodGet, "/v1/videos/multi-image2video/:task_id", ActionMultiImage2Video, "/api/v1/multimodal/kling/videos/multi-image2video/:task_id", RouteKindFetch, ""},
{http.MethodGet, "/v1/videos/multi-image2video", ActionMultiImage2Video, "/api/v1/multimodal/kling/videos/multi-image2video", RouteKindList, ""},
// 视频能力:视频延长
{http.MethodPost, "/v1/videos/video-extend", ActionVideoExtend, "/api/v1/multimodal/kling/videos/video-extend", RouteKindSubmit, ""},
{http.MethodGet, "/v1/videos/video-extend/:task_id", ActionVideoExtend, "/api/v1/multimodal/kling/videos/video-extend/:task_id", RouteKindFetch, ""},
{http.MethodGet, "/v1/videos/video-extend", ActionVideoExtend, "/api/v1/multimodal/kling/videos/video-extend", RouteKindList, ""},

{http.MethodPost, "/v1/videos/multi-elements/init-selection", "kling-multi-elements-init-selection", "/v1/videos/kling/multi-elements/init-selection", RouteKindProxy, ModelKlingElements},
{http.MethodPost, "/v1/videos/multi-elements/add-selection", "kling-multi-elements-add-selection", "/v1/videos/kling/multi-elements/add-selection", RouteKindProxy, ModelKlingElements},
{http.MethodPost, "/v1/videos/multi-elements/delete-selection", "kling-multi-elements-delete-selection", "/v1/videos/kling/multi-elements/delete-selection", RouteKindProxy, ModelKlingElements},
{http.MethodPost, "/v1/videos/multi-elements/clear-selection", "kling-multi-elements-clear-selection", "/v1/videos/kling/multi-elements/clear-selection", RouteKindProxy, ModelKlingElements},
{http.MethodPost, "/v1/videos/multi-elements/preview-selection", "kling-multi-elements-preview-selection", "/v1/videos/kling/multi-elements/preview-selection", RouteKindProxy, ModelKlingElements},
// 主体能力:自定义主体
{http.MethodPost, "/v1/general/advanced-custom-elements", ActionAdvancedElements, "/api/v1/multimodal/kling/general/advanced-custom-elements", RouteKindSubmit, ModelKlingAdvancedElements},
{http.MethodGet, "/v1/general/advanced-custom-elements/:task_id", ActionAdvancedElements, "/api/v1/multimodal/kling/general/advanced-custom-elements/:task_id", RouteKindFetch, ModelKlingAdvancedElements},
{http.MethodGet, "/v1/general/advanced-custom-elements", "kling-advanced-elements-list", "/api/v1/multimodal/kling/general/advanced-custom-elements", RouteKindProxy, ModelKlingAdvancedElements},
// 主体能力:官方主体列表
{http.MethodGet, "/v1/general/advanced-presets-elements", "kling-advanced-elements-presets", "/api/v1/multimodal/kling/general/advanced-presets-elements", RouteKindProxy, ModelKlingAdvancedElements},
// 主体能力:删除自定义主体
{http.MethodPost, "/v1/general/delete-advanced-elements", ActionDeleteElements, "/api/v1/multimodal/kling/general/delete-advanced-elements", RouteKindProxy, ModelKlingAdvancedElements},

{http.MethodPost, "/v1/elements/custom", "kling-elements-custom-create", "/v1/elements/custom", RouteKindProxy, ModelKlingElements},
{http.MethodGet, "/v1/elements/custom", "kling-elements-custom-list", "/v1/elements/custom", RouteKindProxy, ModelKlingElements},
{http.MethodGet, "/v1/elements/:id", "kling-elements-get", "/v1/elements/:id", RouteKindProxy, ModelKlingElements},
{http.MethodGet, "/v1/elements/presets", "kling-elements-presets", "/v1/elements/presets", RouteKindProxy, ModelKlingElements},
{http.MethodPost, "/v1/elements/delete", "kling-elements-delete", "/v1/elements/delete", RouteKindProxy, ModelKlingElements},

{http.MethodPost, "/v1/kling/general/advanced-custom-elements", ActionAdvancedElements, "/v1/kling/general/advanced-custom-elements", RouteKindSubmit, ModelKlingAdvancedElements},
{http.MethodGet, "/v1/kling/general/advanced-custom-elements/:task_id", ActionAdvancedElements, "/v1/kling/general/advanced-custom-elements/:task_id", RouteKindFetch, ModelKlingAdvancedElements},
{http.MethodGet, "/v1/kling/general/advanced-custom-elements", "kling-advanced-elements-list", "/v1/kling/general/advanced-custom-elements", RouteKindProxy, ModelKlingAdvancedElements},
{http.MethodGet, "/v1/kling/general/advanced-presets-elements", "kling-advanced-elements-presets", "/v1/kling/general/advanced-presets-elements", RouteKindProxy, ModelKlingAdvancedElements},
{http.MethodPost, "/v1/kling/general/delete-elements", ActionDeleteElements, "/v1/kling/general/delete-elements", RouteKindProxy, ModelKlingAdvancedElements},

{http.MethodPost, "/v1/kling/general/custom-voices", ActionVoicesCreate, "/v1/kling/general/custom-voices", RouteKindSubmit, ModelCustomVoices},
{http.MethodGet, "/v1/kling/general/custom-voices/:task_id", ActionVoicesCreate, "/v1/kling/general/custom-voices/:task_id", RouteKindFetch, ModelCustomVoices},
{http.MethodGet, "/v1/kling/general/custom-voices", "kling-voices-list", "/v1/kling/general/custom-voices", RouteKindProxy, ModelCustomVoices},
{http.MethodGet, "/v1/kling/general/presets-voices", "kling-voices-presets", "/v1/kling/general/presets-voices", RouteKindProxy, ModelCustomVoices},
{http.MethodPost, "/v1/kling/general/delete-voices", ActionDeleteVoices, "/v1/kling/general/delete-voices", RouteKindProxy, ModelCustomVoices},
// 音色能力:自定义音色
{http.MethodPost, "/v1/general/custom-voices", ActionVoicesCreate, "/api/v1/multimodal/kling/general/custom-voices", RouteKindSubmit, ModelCustomVoices},
{http.MethodGet, "/v1/general/custom-voices/:task_id", ActionVoicesCreate, "/api/v1/multimodal/kling/general/custom-voices/:task_id", RouteKindFetch, ModelCustomVoices},
{http.MethodGet, "/v1/general/custom-voices", "kling-voices-list", "/api/v1/multimodal/kling/general/custom-voices", RouteKindProxy, ModelCustomVoices},
// 音色能力:官方音色列表
{http.MethodGet, "/v1/general/presets-voices", "kling-voices-presets", "/api/v1/multimodal/kling/general/presets-voices", RouteKindProxy, ModelCustomVoices},
// 音色能力:删除自定义音色
{http.MethodPost, "/v1/general/delete-voices", ActionDeleteVoices, "/api/v1/multimodal/kling/general/delete-voices", RouteKindProxy, ModelCustomVoices},
}

func FindRoute(method, pathPattern, kind string) (Route, bool) {
@@ -117,7 +116,6 @@ func Models() []string {
"kling-v3",
"kling-video-o1",
"kling-v3-omni",
ModelKlingElements,
ModelKlingAdvancedElements,
ModelCustomVoices,
}


+ 3
- 3
router/api-router.go Ver ficheiro

@@ -192,9 +192,9 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption)
optionRoute.GET("/model_pricing", controller.GetModelPricingRules)
optionRoute.GET("/model_pricing/:model", controller.GetModelPricingRule)
optionRoute.PUT("/model_pricing/:model", controller.UpdateModelPricingRule)
optionRoute.DELETE("/model_pricing/:model", controller.DeleteModelPricingRule)
optionRoute.GET("/model_pricing/*model", controller.GetModelPricingRule)
optionRoute.PUT("/model_pricing/*model", controller.UpdateModelPricingRule)
optionRoute.DELETE("/model_pricing/*model", controller.DeleteModelPricingRule)
optionRoute.GET("/model_display_pricing", controller.GetModelDisplayPricingRules)
optionRoute.GET("/model_display_pricing/*model", controller.GetModelDisplayPricingRule)
optionRoute.PUT("/model_display_pricing/*model", controller.UpdateModelDisplayPricingRule)


+ 13
- 25
router/video-router.go Ver ficheiro

@@ -25,12 +25,6 @@ func SetVideoRouter(router *gin.Engine) {
klingAipingRouter := router.Group("/v1")
klingAipingRouter.Use(middleware.TokenAuth())
{
klingAipingRouter.POST("/videos/multi-elements/init-selection", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/videos/multi-elements/add-selection", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/videos/multi-elements/delete-selection", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/videos/multi-elements/clear-selection", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/videos/multi-elements/preview-selection", controller.KlingAipingNativeProxy)

klingAipingRouter.POST("/videos/text2video", controller.KlingAipingNativeTaskSubmit)
klingAipingRouter.GET("/videos/text2video/:task_id", controller.KlingAipingNativeTaskFetch)
klingAipingRouter.GET("/videos/text2video", controller.KlingAipingNativeTaskList)
@@ -46,26 +40,20 @@ func SetVideoRouter(router *gin.Engine) {
klingAipingRouter.POST("/videos/multi-image2video", controller.KlingAipingNativeTaskSubmit)
klingAipingRouter.GET("/videos/multi-image2video/:task_id", controller.KlingAipingNativeTaskFetch)
klingAipingRouter.GET("/videos/multi-image2video", controller.KlingAipingNativeTaskList)
klingAipingRouter.POST("/videos/multi-elements", controller.KlingAipingNativeTaskSubmit)
klingAipingRouter.GET("/videos/multi-elements/:task_id", controller.KlingAipingNativeTaskFetch)
klingAipingRouter.GET("/videos/multi-elements", controller.KlingAipingNativeTaskList)

klingAipingRouter.POST("/elements/custom", controller.KlingAipingNativeProxy)
klingAipingRouter.GET("/elements/custom", controller.KlingAipingNativeProxy)
klingAipingRouter.GET("/elements/presets", controller.KlingAipingNativeProxy)
klingAipingRouter.GET("/elements/:id", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/elements/delete", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/videos/video-extend", controller.KlingAipingNativeTaskSubmit)
klingAipingRouter.GET("/videos/video-extend/:task_id", controller.KlingAipingNativeTaskFetch)
klingAipingRouter.GET("/videos/video-extend", controller.KlingAipingNativeTaskList)

klingAipingRouter.POST("/kling/general/advanced-custom-elements", controller.KlingAipingNativeTaskSubmit)
klingAipingRouter.GET("/kling/general/advanced-custom-elements/:task_id", controller.KlingAipingNativeTaskFetch)
klingAipingRouter.GET("/kling/general/advanced-custom-elements", controller.KlingAipingNativeProxy)
klingAipingRouter.GET("/kling/general/advanced-presets-elements", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/kling/general/delete-elements", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/kling/general/custom-voices", controller.KlingAipingNativeTaskSubmit)
klingAipingRouter.GET("/kling/general/custom-voices/:task_id", controller.KlingAipingNativeTaskFetch)
klingAipingRouter.GET("/kling/general/custom-voices", controller.KlingAipingNativeProxy)
klingAipingRouter.GET("/kling/general/presets-voices", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/kling/general/delete-voices", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/general/advanced-custom-elements", controller.KlingAipingNativeTaskSubmit)
klingAipingRouter.GET("/general/advanced-custom-elements/:task_id", controller.KlingAipingNativeTaskFetch)
klingAipingRouter.GET("/general/advanced-custom-elements", controller.KlingAipingNativeProxy)
klingAipingRouter.GET("/general/advanced-presets-elements", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/general/delete-advanced-elements", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/general/custom-voices", controller.KlingAipingNativeTaskSubmit)
klingAipingRouter.GET("/general/custom-voices/:task_id", controller.KlingAipingNativeTaskFetch)
klingAipingRouter.GET("/general/custom-voices", controller.KlingAipingNativeProxy)
klingAipingRouter.GET("/general/presets-voices", controller.KlingAipingNativeProxy)
klingAipingRouter.POST("/general/delete-voices", controller.KlingAipingNativeProxy)
}

// openai compatible API video routes


+ 6
- 6
router/video_router_test.go Ver ficheiro

@@ -17,12 +17,12 @@ func TestKlingAipingNativeRoutesDoNotFallThroughToOpenAIVideoRoutes(t *testing.T
requireHandlerContains(t, r, http.MethodPost, "/v1/videos/text2video", "KlingAipingNativeTaskSubmit")
requireHandlerContains(t, r, http.MethodGet, "/v1/videos/text2video/:task_id", "KlingAipingNativeTaskFetch")
requireHandlerContains(t, r, http.MethodGet, "/v1/videos/text2video", "KlingAipingNativeTaskList")
requireHandlerContains(t, r, http.MethodPost, "/v1/videos/multi-elements/init-selection", "KlingAipingNativeProxy")
requireHandlerContains(t, r, http.MethodPost, "/v1/videos/multi-elements", "KlingAipingNativeTaskSubmit")
requireHandlerContains(t, r, http.MethodGet, "/v1/elements/presets", "KlingAipingNativeProxy")
requireHandlerContains(t, r, http.MethodGet, "/v1/elements/:id", "KlingAipingNativeProxy")
requireHandlerContains(t, r, http.MethodPost, "/v1/kling/general/delete-elements", "KlingAipingNativeProxy")
requireHandlerContains(t, r, http.MethodPost, "/v1/kling/general/delete-voices", "KlingAipingNativeProxy")
requireHandlerContains(t, r, http.MethodPost, "/v1/videos/video-extend", "KlingAipingNativeTaskSubmit")
requireHandlerContains(t, r, http.MethodGet, "/v1/videos/video-extend/:task_id", "KlingAipingNativeTaskFetch")
requireHandlerContains(t, r, http.MethodGet, "/v1/videos/video-extend", "KlingAipingNativeTaskList")
requireHandlerContains(t, r, http.MethodGet, "/v1/general/advanced-presets-elements", "KlingAipingNativeProxy")
requireHandlerContains(t, r, http.MethodPost, "/v1/general/delete-advanced-elements", "KlingAipingNativeProxy")
requireHandlerContains(t, r, http.MethodPost, "/v1/general/delete-voices", "KlingAipingNativeProxy")

requireHandlerContains(t, r, http.MethodPost, "/v1/videos", "RelayTask")
requireHandlerContains(t, r, http.MethodGet, "/v1/videos/:task_id", "RelayTaskFetch")


+ 1
- 1
scripts/deploy-test.sh Ver ficheiro

@@ -11,7 +11,7 @@
set -euo pipefail

DOCKER_REGISTRY="registry.cn-hangzhou.aliyuncs.com/fengsilin/new-api"
PROJECT_DIR="/mnt/d/code/new-api"
PROJECT_DIR="${PROJECT_DIR:-/mnt/d/code/new-api}"

CN_DIR="/root/new-api-cn"
OV_DIR="/root/new-api-ov"


+ 102
- 0
service/kling_asset_channel.go Ver ficheiro

@@ -0,0 +1,102 @@
package service

import (
"errors"
"strings"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"gorm.io/gorm"
)

const KlingAssetChannelType = constant.ChannelTypeKlingAiping

func ResolveKlingAssetChannel(userId int, tokenGroup string) (*model.Channel, error) {
channel, err := GetBoundKlingAssetChannel(userId, tokenGroup)
if err != nil {
return nil, err
}
if channel != nil {
return channel, nil
}

channel, err = AutoMatchKlingAssetChannel(tokenGroup)
if err != nil {
return nil, err
}
if channel == nil {
return nil, nil
}

if err = model.BindUserAssetChannel(userId, KlingAssetChannelType, tokenGroup, channel.Id); err != nil {
return nil, err
}
return channel, nil
}

func GetBoundKlingAssetChannel(userId int, tokenGroup string) (*model.Channel, error) {
binding, err := model.GetUserAssetChannel(userId, KlingAssetChannelType, tokenGroup)
if err != nil {
return nil, err
}
if binding == nil {
return nil, nil
}

channel, err := model.CacheGetChannel(binding.ChannelId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) || common.MemoryCacheEnabled {
return nil, nil
}
return nil, err
}
if !IsUsableKlingAssetChannel(channel, tokenGroup) {
return nil, nil
}
return channel, nil
}

func AutoMatchKlingAssetChannel(tokenGroup string) (*model.Channel, error) {
for startIdx := 0; ; startIdx += DoubaoAssetChannelPageSize {
candidates, err := model.GetChannelsByType(startIdx, DoubaoAssetChannelPageSize, true, KlingAssetChannelType)
if err != nil {
return nil, err
}
for _, candidate := range candidates {
if candidate == nil || !MatchDoubaoAssetGroup(candidate.GetGroups(), tokenGroup) {
continue
}

channel, err := model.CacheGetChannel(candidate.Id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) || common.MemoryCacheEnabled {
continue
}
return nil, err
}
if IsUsableKlingAssetChannel(channel, tokenGroup) {
return channel, nil
}
}
if len(candidates) < DoubaoAssetChannelPageSize {
return nil, nil
}
}
}

func IsUsableKlingAssetChannel(channel *model.Channel, tokenGroup string) bool {
if channel == nil {
return false
}
if channel.Type != KlingAssetChannelType {
return false
}
if channel.Status != common.ChannelStatusEnabled {
return false
}
if strings.TrimSpace(channel.Key) == "" || len(channel.GetKeys()) == 0 {
return false
}
return MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup)
}

+ 64
- 0
service/kling_asset_channel_test.go Ver ficheiro

@@ -0,0 +1,64 @@
package service

import (
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestResolveKlingAssetChannel_PrefersBoundChannel(t *testing.T) {
db := setupDoubaoAssetChannelDB(t)
createDoubaoAssetChannelForTest(t, db, 1, constant.ChannelTypeKlingAiping, "default", "bound-key", common.ChannelStatusEnabled)
createDoubaoAssetChannelForTest(t, db, 2, constant.ChannelTypeKlingAiping, "default", "auto-key", common.ChannelStatusEnabled)
require.NoError(t, model.BindUserAssetChannel(10, constant.ChannelTypeKlingAiping, "default", 1))

channel, err := ResolveKlingAssetChannel(10, "default")

require.NoError(t, err)
require.NotNil(t, channel)
assert.Equal(t, 1, channel.Id)
assert.Equal(t, "bound-key", channel.Key)
}

func TestResolveKlingAssetChannel_AutoMatchesAndPersistsWhenUnbound(t *testing.T) {
db := setupDoubaoAssetChannelDB(t)
createDoubaoAssetChannelForTest(t, db, 1, constant.ChannelTypeKlingAiping, "default", "auto-key", common.ChannelStatusEnabled)

channel, err := ResolveKlingAssetChannel(10, "default")

require.NoError(t, err)
require.NotNil(t, channel)
assert.Equal(t, 1, channel.Id)

binding, err := model.GetUserAssetChannel(10, constant.ChannelTypeKlingAiping, "default")
require.NoError(t, err)
require.NotNil(t, binding)
assert.Equal(t, 1, binding.ChannelId)
}

func TestResolveKlingAssetChannel_IgnoresDisabledChannel(t *testing.T) {
db := setupDoubaoAssetChannelDB(t)
createDoubaoAssetChannelForTest(t, db, 1, constant.ChannelTypeKlingAiping, "default", "disabled-key", common.ChannelStatusAutoDisabled)
createDoubaoAssetChannelForTest(t, db, 2, constant.ChannelTypeKlingAiping, "default", "enabled-key", common.ChannelStatusEnabled)
require.NoError(t, model.BindUserAssetChannel(10, constant.ChannelTypeKlingAiping, "default", 1))

channel, err := ResolveKlingAssetChannel(10, "default")

require.NoError(t, err)
require.NotNil(t, channel)
assert.Equal(t, 2, channel.Id)
}

func TestIsUsableKlingAssetChannel_RejectsOtherChannelType(t *testing.T) {
ch := &model.Channel{Type: constant.ChannelTypeDoubaoVideoCompatibleAiping, Key: "k", Status: common.ChannelStatusEnabled, Group: "default"}
assert.False(t, IsUsableKlingAssetChannel(ch, "default"))
}

func TestIsUsableKlingAssetChannel_RejectsGroupMismatch(t *testing.T) {
ch := &model.Channel{Type: constant.ChannelTypeKlingAiping, Key: "k", Status: common.ChannelStatusEnabled, Group: "vip"}
assert.False(t, IsUsableKlingAssetChannel(ch, "default"))
}

+ 142
- 0
service/task_billing_test.go Ver ficheiro

@@ -3,13 +3,17 @@ package service
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"
@@ -666,6 +670,76 @@ func TestCASGuardedSettle_Win(t *testing.T) {
assert.Equal(t, actualQuota, task.Quota)
}

func TestUpdateVideoTasks_ChannelMissingRefundsPreConsumedQuota(t *testing.T) {
truncate(t)
ctx := context.Background()

const userID, tokenID, channelID = 24, 24, 424242
const initQuota, preConsumed = 10000, 3500
const tokenRemain = 7000

seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-missing-channel-refund", tokenRemain)

task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.Status = model.TaskStatus(model.TaskStatusInProgress)
task.Progress = "50%"
require.NoError(t, model.DB.Create(task).Error)

err := UpdateVideoTasks(ctx, "missing-channel-platform", map[int][]string{
channelID: {task.GetUpstreamTaskID()},
}, map[string]*model.Task{
task.GetUpstreamTaskID(): task,
})
require.NoError(t, err)

var reloaded model.Task
require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status)
assert.Equal(t, "100%", reloaded.Progress)
assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))

log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
}

func TestUpdateSunoTasks_ChannelMissingRefundsPreConsumedQuota(t *testing.T) {
truncate(t)
ctx := context.Background()

const userID, tokenID, channelID = 25, 25, 525252
const initQuota, preConsumed = 10000, 2500
const tokenRemain = 6000

seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-suno-missing-channel-refund", tokenRemain)

task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.Status = model.TaskStatus(model.TaskStatusInProgress)
task.Progress = "50%"
require.NoError(t, model.DB.Create(task).Error)

err := UpdateSunoTasks(ctx, map[int][]string{
channelID: {task.GetUpstreamTaskID()},
}, map[string]*model.Task{
task.GetUpstreamTaskID(): task,
})
require.NoError(t, err)

var reloaded model.Task
require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status)
assert.Equal(t, "100%", reloaded.Progress)
assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))

log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
}

func TestNonTerminalUpdate_NoBilling(t *testing.T) {
truncate(t)
ctx := context.Background()
@@ -713,6 +787,74 @@ func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.Task
return m.adjustReturn
}

type mockSunoPollingAdaptor struct {
mockAdaptor
body string
}

func (m *mockSunoPollingAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(m.body)),
}, nil
}

func TestUpdateSunoTasks_CASLostDoesNotRefundAgain(t *testing.T) {
truncate(t)
ctx := context.Background()

const userID, tokenID, channelID = 26, 26, 26
const initQuota, preConsumed = 10000, 2000
const tokenRemain = 5000

seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-suno-cas-lost", tokenRemain)
seedChannel(t, channelID)

task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.TaskID = "task_suno_cas_lost"
task.PrivateData.UpstreamTaskID = "suno_upstream_cas_lost"
task.Status = model.TaskStatus(model.TaskStatusInProgress)
task.Progress = "50%"
require.NoError(t, model.DB.Create(task).Error)

// Another poller already transitioned the task. This stale in-memory task must not refund again.
require.NoError(t, model.DB.Model(&model.Task{}).Where("id = ?", task.ID).Updates(map[string]any{
"status": model.TaskStatusFailure,
"progress": "100%",
"fail_reason": "already failed",
}).Error)

body, err := common.Marshal(dto.TaskResponse[[]dto.SunoDataResponse]{
Code: dto.TaskSuccessCode,
Data: []dto.SunoDataResponse{{
TaskID: task.GetUpstreamTaskID(),
Status: model.TaskStatusFailure,
FailReason: "upstream failed",
Data: json.RawMessage(`{}`),
}},
})
require.NoError(t, err)

oldGetTaskAdaptor := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(platform constant.TaskPlatform) TaskPollingAdaptor {
require.Equal(t, constant.TaskPlatformSuno, platform)
return &mockSunoPollingAdaptor{body: string(body)}
}
t.Cleanup(func() { GetTaskAdaptorFunc = oldGetTaskAdaptor })

err = UpdateSunoTasks(ctx, map[int][]string{
channelID: {task.GetUpstreamTaskID()},
}, map[string]*model.Task{
task.GetUpstreamTaskID(): task,
})
require.NoError(t, err)

assert.Equal(t, initQuota, getUserQuota(t, userID))
assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
assert.Equal(t, int64(0), countLogs(t))
}

// ===========================================================================
// PerCallBilling tests — settleTaskBillingOnComplete
// ===========================================================================


+ 42
- 33
service/task_polling.go Ver ficheiro

@@ -152,6 +152,42 @@ func DispatchPlatformUpdate(platform constant.TaskPlatform, taskChannelM map[int
}
}

func failTaskWithRefund(ctx context.Context, task *model.Task, reason string, finishTime int64, fromStatus ...model.TaskStatus) {
if task == nil {
return
}
oldStatus := task.Status
if len(fromStatus) > 0 {
oldStatus = fromStatus[0]
}
task.Status = model.TaskStatusFailure
task.Progress = taskcommon.ProgressComplete
task.FinishTime = finishTime
task.FailReason = reason

won, err := task.UpdateWithStatus(oldStatus)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("failTaskWithRefund CAS update error for task %s: %v", task.TaskID, err))
return
}
if !won {
logger.LogInfo(ctx, fmt.Sprintf("failTaskWithRefund: task %s already transitioned, skip refund", task.TaskID))
return
}
if task.Quota != 0 {
RefundTaskQuota(ctx, task, reason)
}
}

func failTasksForChannel(ctx context.Context, taskIds []string, taskM map[string]*model.Task, reason string) {
now := time.Now().Unix()
for _, upstreamID := range taskIds {
if t, ok := taskM[upstreamID]; ok {
failTaskWithRefund(ctx, t, reason, now)
}
}
}

// UpdateSunoTasks 按渠道更新所有 Suno 任务
func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM {
@@ -171,22 +207,8 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM
ch, err := model.CacheGetChannel(channelId)
if err != nil {
common.SysLog(fmt.Sprintf("CacheGetChannel: %v", err))
// Collect DB primary key IDs for bulk update (taskIds are upstream IDs, not task_id column values)
var failedIDs []int64
for _, upstreamID := range taskIds {
if t, ok := taskM[upstreamID]; ok {
failedIDs = append(failedIDs, t.ID)
}
}
err = model.TaskBulkUpdateByID(failedIDs, map[string]any{
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
common.SysLog(fmt.Sprintf("UpdateSunoTask error: %v", err))
}
return err
failTasksForChannel(ctx, taskIds, taskM, fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId))
return fmt.Errorf("CacheGetChannel failed: %w", err)
}
adaptor := GetTaskAdaptorFunc(constant.TaskPlatformSuno)
if adaptor == nil {
@@ -227,6 +249,7 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM
continue
}

oldStatus := task.Status
task.Status = lo.If(model.TaskStatus(responseItem.Status) != "", model.TaskStatus(responseItem.Status)).Else(task.Status)
task.FailReason = lo.If(responseItem.FailReason != "", responseItem.FailReason).Else(task.FailReason)
task.SubmitTime = lo.If(responseItem.SubmitTime != 0, responseItem.SubmitTime).Else(task.SubmitTime)
@@ -234,8 +257,8 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM
task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime)
if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure {
logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
task.Progress = "100%"
RefundTaskQuota(ctx, task, task.FailReason)
failTaskWithRefund(ctx, task, task.FailReason, task.FinishTime, oldStatus)
continue
}
if responseItem.Status == model.TaskStatusSuccess {
task.Progress = "100%"
@@ -305,21 +328,7 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann
}
cacheGetChannel, err := model.CacheGetChannel(channelId)
if err != nil {
// Collect DB primary key IDs for bulk update (taskIds are upstream IDs, not task_id column values)
var failedIDs []int64
for _, upstreamID := range taskIds {
if t, ok := taskM[upstreamID]; ok {
failedIDs = append(failedIDs, t.ID)
}
}
errUpdate := model.TaskBulkUpdateByID(failedIDs, map[string]any{
"fail_reason": fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId),
"status": "FAILURE",
"progress": "100%",
})
if errUpdate != nil {
common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
}
failTasksForChannel(ctx, taskIds, taskM, fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId))
return fmt.Errorf("CacheGetChannel failed: %w", err)
}
adaptor := GetTaskAdaptorFunc(platform)


+ 33
- 5
web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx Ver ficheiro

@@ -6,6 +6,7 @@ const sectionStyle = {
borderRadius: 6,
background: 'var(--semi-color-bg-1)',
overflow: 'hidden',
minWidth: 0,
};

const headerStyle = {
@@ -20,7 +21,7 @@ const bodyStyle = {

const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(280px, 100%), 1fr))',
gap: '10px 16px',
};

@@ -28,18 +29,45 @@ const itemStyle = {
minWidth: 0,
};

const fullWidthItemStyle = {
...itemStyle,
gridColumn: '1 / -1',
};

const valueStyle = {
marginTop: 3,
lineHeight: 1.55,
wordBreak: 'break-word',
};

const fullWidthValueStyle = {
...valueStyle,
width: '100%',
maxWidth: '100%',
};

const expandedContainerStyle = {
padding: '12px 16px',
width: '100%',
boxSizing: 'border-box',
};

const sectionsGridStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
gap: 12,
alignItems: 'start',
width: '100%',
};

const DetailItem = ({ item }) => (
<div style={itemStyle}>
<div style={item?.fullWidth ? fullWidthItemStyle : itemStyle}>
<Typography.Text type='tertiary' size='small'>
{item.key}
</Typography.Text>
<div style={valueStyle}>{item.value}</div>
<div style={item?.fullWidth ? fullWidthValueStyle : valueStyle}>
{item.value}
</div>
</div>
);

@@ -64,8 +92,8 @@ const DetailSection = ({ section }) => {
const UsageLogExpandedDetail = ({ detail }) => {
if (detail?.sections?.length) {
return (
<div style={{ padding: '12px 16px' }}>
<div style={{ display: 'grid', gap: 12 }}>
<div style={expandedContainerStyle}>
<div style={sectionsGridStyle}>
{detail.sections.map((section) => (
<DetailSection key={section.title} section={section} />
))}


+ 102
- 0
web/src/components/table/usage-logs/UsageLogExpandedDetailData.js Ver ficheiro

@@ -0,0 +1,102 @@
export function isViolationFeeLog(other) {
return (
other?.violation_fee === true ||
Boolean(other?.violation_fee_code) ||
Boolean(other?.violation_fee_marker)
);
}

export function shouldShowModelPrice(modelPrice) {
return modelPrice !== undefined && modelPrice !== null && modelPrice !== -1;
}

export function shouldShowUserGroupRatio(userGroupRatio) {
return (
Number.isFinite(Number(userGroupRatio)) && Number(userGroupRatio) !== -1
);
}

/**
* 构建计费计算项(纯数据层,不包含渲染逻辑)。
* 返回 `{ type, params, key, fullWidth }`,由调用方负责调用对应的 renderer。
*/
export function buildBillingCalculationItem({ t, record, other }) {
if (!record || record.type !== 2 || isViolationFeeLog(other)) {
return null;
}

let type;
let params;

if (other?.ws || other?.audio) {
type = 'audio';
params = [
other?.text_input,
other?.text_output,
other?.model_ratio,
other?.model_price,
other?.completion_ratio,
other?.audio_input,
other?.audio_output,
other?.audio_ratio,
other?.audio_completion_ratio,
other?.group_ratio,
other?.user_group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
];
} else if (other?.claude) {
type = 'claude';
params = [
record.prompt_tokens,
record.completion_tokens,
other.model_ratio,
other.model_price,
other.completion_ratio,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
];
} else {
type = 'standard';
params = [
record.prompt_tokens,
record.completion_tokens,
other?.model_ratio,
other?.model_price,
other?.completion_ratio,
other?.group_ratio,
other?.user_group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
other?.image || false,
other?.image_ratio || 0,
other?.image_output || 0,
other?.web_search || false,
other?.web_search_call_count || 0,
other?.web_search_price || 0,
other?.file_search || false,
other?.file_search_call_count || 0,
other?.file_search_price || 0,
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
other?.image_generation_call || false,
other?.image_generation_call_price || 0,
];
}

return {
key: t('计费过程'),
type,
params,
fullWidth: true,
};
}

+ 69
- 0
web/src/components/table/usage-logs/UsageLogExpandedDetailData.test.js Ver ficheiro

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import {
buildBillingCalculationItem,
shouldShowModelPrice,
shouldShowUserGroupRatio,
} from './UsageLogExpandedDetailData.js';

const t = (key) => key;

describe('buildBillingCalculationItem', () => {
it('returns structured data for LLM usage logs', () => {
const item = buildBillingCalculationItem({
t,
record: {
type: 2,
prompt_tokens: 1000,
completion_tokens: 500,
quota: 10,
},
other: {
model_ratio: 2,
model_price: 0.01,
completion_ratio: 3,
group_ratio: 1.5,
user_group_ratio: 0.8,
},
});

expect(item).toMatchObject({
key: '计费过程',
type: 'standard',
fullWidth: true,
});
expect(item.params).toEqual([
1000, 500, 2, 0.01, 3, 1.5, 0.8,
0, 1,
false, 0, 0,
false, 0, 0,
false, 0, 0,
false, 0, 0,
false, 0,
]);
});

it('does not return billing calculation for violation fee logs', () => {
const item = buildBillingCalculationItem({
t,
record: {
type: 2,
prompt_tokens: 1000,
completion_tokens: 500,
},
other: {
violation_fee: true,
model_ratio: 2,
model_price: 0.01,
},
});

expect(item).toBeNull();
});

it('hides sentinel model price and user group ratio values', () => {
expect(shouldShowModelPrice(-1)).toBe(false);
expect(shouldShowModelPrice(0.01)).toBe(true);
expect(shouldShowUserGroupRatio(-1)).toBe(false);
expect(shouldShowUserGroupRatio(0.8)).toBe(true);
});
});

+ 28
- 2
web/src/hooks/usage-logs/useUsageLogsData.jsx Ver ficheiro

@@ -39,6 +39,17 @@ import {
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { mergeTaskBillingLogs } from './taskBillingLogMerge';
import {
buildBillingCalculationItem,
shouldShowModelPrice,
shouldShowUserGroupRatio,
} from '../../components/table/usage-logs/UsageLogExpandedDetailData';

const billingRenderers = {
standard: renderModelPrice,
audio: renderAudioModelPrice,
claude: renderClaudeModelPrice,
};

export const useLogsData = () => {
const { t } = useTranslation();
@@ -410,7 +421,7 @@ export const useLogsData = () => {
addKnown('billing', t('花费'), renderQuota(log.quota, 6));
addKnown('billing', t('Prompt Tokens'), log.prompt_tokens);
addKnown('billing', t('Completion Tokens'), log.completion_tokens);
if (other?.model_price !== undefined) {
if (shouldShowModelPrice(other?.model_price)) {
const quotaPerUnit = parseFloat(
localStorage.getItem('quota_per_unit') || '500000',
);
@@ -433,7 +444,7 @@ export const useLogsData = () => {
if (other?.group_ratio !== undefined) {
addKnown('billing', t('分组倍率'), other.group_ratio);
}
if (other?.user_group_ratio !== undefined) {
if (shouldShowUserGroupRatio(other?.user_group_ratio)) {
addKnown('billing', t('用户分组倍率'), other.user_group_ratio);
}
if (other?.billing_mode) {
@@ -469,6 +480,21 @@ export const useLogsData = () => {
if (other?.total_tokens !== undefined) {
addKnown('billing', t('总 Tokens'), other.total_tokens);
}
const billingCalculationItem = buildBillingCalculationItem({
t,
record: log,
other,
});
if (billingCalculationItem) {
addKnown(
'billing',
billingCalculationItem.key,
billingRenderers[billingCalculationItem.type](
...billingCalculationItem.params,
),
);
sections.billing[sections.billing.length - 1].fullWidth = true;
}

if (other?.pricing_snapshot) {
const snapshot = other.pricing_snapshot || {};


+ 0
- 277
web/src/pages/Setting/Operation/ModelPricing/DisplayPricing.jsx Ver ficheiro

@@ -1,277 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button,
Input,
Popconfirm,
SideSheet,
Space,
Table,
Tag,
TextArea,
Typography,
} from '@douyinfe/semi-ui';
import { Edit, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../../../helpers';
import {
createDefaultDisplayPricingItems,
formatDisplayPricingJson,
normalizeDisplayPricingItems,
parseDisplayPricingJson,
} from './displayPricingConfig';

export default function DisplayPricing() {
const { t } = useTranslation();
const [rules, setRules] = useState({});
const [keyword, setKeyword] = useState('');
const [editingModel, setEditingModel] = useState(null);
const [editingItems, setEditingItems] = useState([]);
const [loading, setLoading] = useState(false);

const refresh = async () => {
setLoading(true);
try {
const res = await API.get('/api/option/model_display_pricing');
if (!res.data?.success) {
showError(res.data?.message || t('加载展示价格失败'));
return;
}
setRules(res.data.data || {});
} catch (error) {
showError(error.message || t('加载展示价格失败'));
} finally {
setLoading(false);
}
};

useEffect(() => {
refresh();
}, []);

const data = useMemo(
() =>
Object.entries(rules || {})
.filter(([model]) =>
model.toLowerCase().includes(keyword.toLowerCase()),
)
.sort(([left], [right]) => left.localeCompare(right))
.map(([model, items]) => {
const normalized = normalizeDisplayPricingItems(items);
return {
model,
count: normalized.length,
preview: normalized.slice(0, 3),
items: normalized,
};
}),
[rules, keyword],
);

const openEditor = (model, items) => {
setEditingModel(model);
setEditingItems(items || createDefaultDisplayPricingItems());
};

const remove = async (model) => {
try {
const res = await API.delete(
`/api/option/model_display_pricing/${encodeURIComponent(model)}`,
);
if (!res.data?.success) {
showError(res.data?.message || t('删除展示价格失败'));
return;
}
showSuccess(t('展示价格已删除'));
await refresh();
} catch (error) {
showError(error.message || t('删除展示价格失败'));
}
};

const columns = [
{ title: t('模型'), dataIndex: 'model' },
{ title: t('规格数量'), dataIndex: 'count', width: 110 },
{
title: t('规格预览'),
dataIndex: 'preview',
render: (items) => (
<Space wrap>
{(items || []).map((item) => (
<Tag key={item.specification}>
{item.specification}: {item.price} / {item.unit}
</Tag>
))}
</Space>
),
},
{
title: t('操作'),
width: 120,
render: (_, row) => (
<Space>
<Button
icon={<Edit size={16} />}
theme='borderless'
data-testid={`display-pricing-edit-${row.model}`}
onClick={() => openEditor(row.model, row.items)}
/>
<Popconfirm
title={t('删除后用户端不再展示该模型的展示价格,是否继续?')}
onConfirm={() => remove(row.model)}
>
<Button
icon={<Trash2 size={16} />}
type='danger'
theme='borderless'
/>
</Popconfirm>
</Space>
),
},
];

return (
<Space vertical align='start' style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Space vertical align='start' spacing={2}>
<Typography.Title heading={5} style={{ margin: 0 }}>
{t('展示价格')}
</Typography.Title>
<Typography.Text type='tertiary'>
{t(
'仅用于模型广场展示和用户估算,不参与真实扣费。price 按 USD 保存,用户端会按站点货币设置换算展示。',
)}
</Typography.Text>
</Space>
<Space>
<Button
icon={<RefreshCw size={16} />}
onClick={refresh}
loading={loading}
>
{t('刷新')}
</Button>
<Button
icon={<Plus size={16} />}
type='primary'
data-testid='display-pricing-add-model'
onClick={() => openEditor('', createDefaultDisplayPricingItems())}
>
{t('添加展示价格')}
</Button>
</Space>
</Space>
<Input
placeholder={t('搜索模型')}
value={keyword}
onChange={setKeyword}
style={{ width: 280 }}
data-testid='display-pricing-search'
/>
<Table
columns={columns}
dataSource={data}
rowKey='model'
pagination={{ pageSize: 10 }}
loading={loading}
data-testid='display-pricing-table'
/>
<DisplayPricingEditor
visible={editingModel !== null}
modelName={editingModel || ''}
items={editingItems}
onClose={() => setEditingModel(null)}
onSaved={refresh}
/>
</Space>
);
}

function DisplayPricingEditor({ visible, modelName, items, onClose, onSaved }) {
const { t } = useTranslation();
const [name, setName] = useState(modelName || '');
const [jsonText, setJsonText] = useState(formatDisplayPricingJson(items));
const [saving, setSaving] = useState(false);

useEffect(() => {
if (!visible) return;
setName(modelName || '');
setJsonText(formatDisplayPricingJson(items));
}, [visible, modelName, items]);

const save = async () => {
if (!name.trim()) {
showError(t('模型名称不能为空'));
return;
}
const parsed = parseDisplayPricingJson(jsonText);
if (!parsed.ok) {
showError(`${t('JSON 格式错误')}: ${parsed.error}`);
return;
}

setSaving(true);
try {
const res = await API.put(
`/api/option/model_display_pricing/${encodeURIComponent(name.trim())}`,
parsed.items,
);
if (!res.data?.success) {
showError(res.data?.message || t('保存展示价格失败'));
return;
}
showSuccess(t('展示价格已保存'));
await onSaved?.();
onClose?.();
} catch (error) {
showError(error.message || t('保存展示价格失败'));
} finally {
setSaving(false);
}
};

return (
<SideSheet
visible={visible}
title={modelName ? t('编辑展示价格') : t('添加展示价格')}
width='min(960px, calc(100vw - 48px))'
onCancel={onClose}
data-testid='display-pricing-editor'
footer={
<Space>
<Button onClick={onClose}>{t('取消')}</Button>
<Button
type='primary'
onClick={save}
loading={saving}
data-testid='display-pricing-save'
>
{t('保存')}
</Button>
</Space>
}
>
<Space vertical align='start' style={{ width: '100%' }}>
<Input
prefix={t('模型')}
value={name}
onChange={setName}
style={{ width: 320 }}
disabled={Boolean(modelName)}
data-testid='display-pricing-model-name'
/>
<Typography.Text type='tertiary'>
{t(
'填写数组 JSON。每一项包含 specification、official_supplier_tip、price、unit、sort_order、discount_rate;price 使用 USD 基准价。',
)}
</Typography.Text>
<TextArea
value={jsonText}
onChange={setJsonText}
autosize={{ minRows: 18, maxRows: 30 }}
data-testid='display-pricing-json-editor'
/>
</Space>
</SideSheet>
);
}

+ 149
- 0
web/src/pages/Setting/Operation/ModelPricing/DisplayPricingForm.jsx Ver ficheiro

@@ -0,0 +1,149 @@
import React from 'react';
import {
Button,
Input,
InputNumber,
Select,
Space,
Table,
} from '@douyinfe/semi-ui';
import { Plus, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { createDisplayPricingRow } from './displayPricingConfig';

const UNIT_OPTIONS = ['second', 'call', 'image', 'minute', 'token'];

export default function DisplayPricingForm({ value, onChange }) {
const { t } = useTranslation();
const rows = Array.isArray(value) ? value : [];

const update = (index, patch) => {
const next = [...rows];
next[index] = { ...next[index], ...patch };
onChange(next);
};

const add = () => onChange([...rows, createDisplayPricingRow()]);
const remove = (index) => onChange(rows.filter((_, i) => i !== index));

const columns = [
{
title: t('规格'),
dataIndex: 'specification',
width: 150,
render: (value, _, index) => (
<Input
value={value || ''}
placeholder='768p-6s'
onChange={(specification) => update(index, { specification })}
/>
),
},
{
title: t('供应商提示'),
dataIndex: 'official_supplier_tip',
render: (value, _, index) => (
<Input
value={value || ''}
placeholder={t('用于用户端说明')}
onChange={(official_supplier_tip) =>
update(index, { official_supplier_tip })
}
/>
),
},
{
title: t('展示价格'),
dataIndex: 'price',
width: 150,
render: (value, _, index) => (
<InputNumber
value={Number(value || 0)}
min={0}
step={0.000001}
prefix='$'
style={{ width: 130 }}
onChange={(price) => update(index, { price })}
/>
),
},
{
title: t('单位'),
dataIndex: 'unit',
width: 140,
render: (value, _, index) => (
<Select
value={value || undefined}
placeholder='second'
style={{ width: 120 }}
allowCreate
onChange={(unit) => update(index, { unit })}
>
{UNIT_OPTIONS.map((unit) => (
<Select.Option key={unit} value={unit}>
{unit}
</Select.Option>
))}
</Select>
),
},
{
title: t('排序'),
dataIndex: 'sort_order',
width: 110,
render: (value, _, index) => (
<InputNumber
value={Number(value || 0)}
step={1}
style={{ width: 90 }}
onChange={(sort_order) => update(index, { sort_order })}
/>
),
},
{
title: t('折扣率'),
dataIndex: 'discount_rate',
width: 120,
render: (value, _, index) => (
<InputNumber
value={Number(value ?? 1)}
min={0}
step={0.01}
style={{ width: 100 }}
onChange={(discount_rate) => update(index, { discount_rate })}
/>
),
},
{
title: t('操作'),
width: 80,
render: (_, __, index) => (
<Button
icon={<Trash2 size={16} />}
type='danger'
theme='borderless'
onClick={() => remove(index)}
/>
),
},
];

return (
<Space vertical align='start' style={{ width: '100%' }}>
<Table
columns={columns}
dataSource={rows}
rowKey={(_, index) => `${index}`}
pagination={false}
data-testid='display-pricing-form-table'
/>
<Button
icon={<Plus size={16} />}
onClick={add}
data-testid='display-pricing-add-row'
>
{t('添加规格')}
</Button>
</Space>
);
}

+ 161
- 88
web/src/pages/Setting/Operation/ModelPricing/Editor.jsx Ver ficheiro

@@ -17,6 +17,12 @@ import DimensionEditor from './DimensionEditor';
import GeneratorModal from './GeneratorModal';
import PreviewPanel from './PreviewPanel';
import PricingGrid from './PricingGrid';
import DisplayPricingForm from './DisplayPricingForm';
import {
getEditableDisplayPricingItems,
shouldSaveBillingConfig,
validateDisplayPricingFormItems,
} from './displayPricingConfig';
import {
BILLING_UNITS,
FALLBACKS,
@@ -28,6 +34,8 @@ export default function Editor({
visible,
modelName,
config,
hasExistingBillingConfig,
displayPricingItems,
onClose,
onSaved,
}) {
@@ -35,6 +43,9 @@ export default function Editor({
const [name, setName] = useState(modelName || '');
const [draft, setDraft] = useState(createDefaultConfig());
const [jsonText, setJsonText] = useState('');
const [displayPricingItemsDraft, setDisplayPricingItemsDraft] = useState([]);
const [displayPricingDirty, setDisplayPricingDirty] = useState(false);
const [billingDirty, setBillingDirty] = useState(false);
const [history, setHistory] = useState([]);
const [future, setFuture] = useState([]);
const [generatorVisible, setGeneratorVisible] = useState(false);
@@ -46,15 +57,24 @@ export default function Editor({
setName(modelName || '');
setDraft(next);
setJsonText(JSON.stringify(next, null, 2));
setDisplayPricingItemsDraft(
getEditableDisplayPricingItems(
modelName ? { [modelName]: displayPricingItems } : {},
modelName,
),
);
setDisplayPricingDirty(false);
setBillingDirty(false);
setHistory([]);
setFuture([]);
}, [visible, modelName, config]);
}, [visible, modelName, config, displayPricingItems]);

const validation = useMemo(() => validatePricingConfig(draft), [draft]);

const updateDraft = (next) => {
setHistory((items) => [...items, draft]);
setFuture([]);
setBillingDirty(true);
setDraft(next);
setJsonText(JSON.stringify(next, null, 2));
};
@@ -95,15 +115,41 @@ export default function Editor({
showError(validation.errors.join('\n'));
return;
}
const parsedDisplayPricing = validateDisplayPricingFormItems(
displayPricingItemsDraft,
);
if (!parsedDisplayPricing.ok) {
showError(`${t('展示价格配置错误')}: ${parsedDisplayPricing.error}`);
return;
}
const shouldSaveDisplayPricing = displayPricingDirty;
const shouldSaveBilling = shouldSaveBillingConfig({
isNewModel: !modelName,
hasExistingBillingConfig,
billingDirty,
displayPricingDirty,
});
setSaving(true);
try {
const res = await API.put(
`/api/option/model_pricing/${encodeURIComponent(name.trim())}`,
draft,
);
if (!res.data?.success) {
showError(res.data?.message || t('保存模型多维计费失败'));
return;
if (shouldSaveBilling) {
const res = await API.put(
`/api/option/model_pricing/${encodeURIComponent(name.trim())}`,
draft,
);
if (!res.data?.success) {
showError(res.data?.message || t('保存模型多维计费失败'));
return;
}
}
if (shouldSaveDisplayPricing) {
const displayRes = await API.put(
`/api/option/model_display_pricing/${encodeURIComponent(name.trim())}`,
parsedDisplayPricing.items,
);
if (!displayRes.data?.success) {
showError(displayRes.data?.message || t('保存展示价格失败'));
return;
}
}
showSuccess(t('模型多维计费已保存'));
await onSaved?.();
@@ -156,88 +202,115 @@ export default function Editor({
</Space>
}
>
<Space vertical align='start' style={{ width: '100%' }}>
<Space wrap>
<Input
prefix={t('模型')}
value={name}
onChange={setName}
style={{ width: 260 }}
disabled={Boolean(modelName)}
data-testid='model-pricing-model-name'
/>
<Select
value={draft.billing_unit}
onChange={setBillingUnit}
style={{ width: 180 }}
>
<Select.Option value='per_call'>{t('每次调用')}</Select.Option>
<Select.Option value='per_1m_tokens'>
{t('每百万 tokens')}
</Select.Option>
</Select>
<Select
value={draft.fallback?.strategy}
onChange={(value) =>
updateDraft({
...draft,
fallback: { ...draft.fallback, strategy: value },
})
}
style={{ width: 160 }}
>
<Select.Option value={FALLBACKS.reject}>{t('拒绝')}</Select.Option>
<Select.Option value={FALLBACKS.max}>
{t('使用最高价')}
</Select.Option>
<Select.Option value={FALLBACKS.default}>
{t('使用默认价')}
</Select.Option>
</Select>
<Button
icon={<Wand2 size={16} />}
data-testid='model-pricing-open-generator'
onClick={() => setGeneratorVisible(true)}
>
{t('生成价格行')}
</Button>
</Space>
{!validation.ok && (
<Typography.Text type='danger' style={{ whiteSpace: 'pre-wrap' }}>
{validation.errors.join('\n')}
</Typography.Text>
)}
<Tabs type='line' style={{ width: '100%' }}>
<TabPane tab={t('维度')} itemKey='dimensions'>
<DimensionEditor
dimensions={draft.dimensions || []}
onChange={(dimensions) => updateDraft({ ...draft, dimensions })}
/>
</TabPane>
<TabPane tab={t('价格表')} itemKey='table'>
<PricingGrid
dimensions={draft.dimensions || []}
rows={draft.table || []}
billingUnit={draft.billing_unit}
onChange={(table) => updateDraft({ ...draft, table })}
<Tabs type='line' style={{ width: '100%' }}>
<TabPane tab={t('真实计费')} itemKey='billing'>
<Space vertical align='start' style={{ width: '100%' }}>
<Space wrap>
<Input
prefix={t('模型')}
value={name}
onChange={setName}
style={{ width: 260 }}
disabled={Boolean(modelName)}
data-testid='model-pricing-model-name'
/>
<Select
value={draft.billing_unit}
onChange={setBillingUnit}
style={{ width: 180 }}
>
<Select.Option value='per_call'>{t('每次调用')}</Select.Option>
<Select.Option value='per_1m_tokens'>
{t('每百万 tokens')}
</Select.Option>
</Select>
<Select
value={draft.fallback?.strategy}
onChange={(value) =>
updateDraft({
...draft,
fallback: { ...draft.fallback, strategy: value },
})
}
style={{ width: 160 }}
>
<Select.Option value={FALLBACKS.reject}>
{t('拒绝')}
</Select.Option>
<Select.Option value={FALLBACKS.max}>
{t('使用最高价')}
</Select.Option>
<Select.Option value={FALLBACKS.default}>
{t('使用默认价')}
</Select.Option>
</Select>
<Button
icon={<Wand2 size={16} />}
data-testid='model-pricing-open-generator'
onClick={() => setGeneratorVisible(true)}
>
{t('生成价格表')}
</Button>
</Space>
{!validation.ok && (
<Typography.Text type='danger' style={{ whiteSpace: 'pre-wrap' }}>
{validation.errors.join('\n')}
</Typography.Text>
)}
<Tabs type='line' style={{ width: '100%' }}>
<TabPane tab={t('维度')} itemKey='dimensions'>
<DimensionEditor
dimensions={draft.dimensions || []}
onChange={(dimensions) =>
updateDraft({ ...draft, dimensions })
}
/>
</TabPane>
<TabPane tab={t('价格表')} itemKey='table'>
<PricingGrid
dimensions={draft.dimensions || []}
rows={draft.table || []}
billingUnit={draft.billing_unit}
onChange={(table) => updateDraft({ ...draft, table })}
/>
</TabPane>
<TabPane tab={t('预览')} itemKey='preview'>
<PreviewPanel config={draft} />
</TabPane>
<TabPane tab={t('JSON')} itemKey='json'>
<TextArea
value={jsonText}
onChange={setJsonText}
autosize={{ minRows: 18, maxRows: 28 }}
data-testid='model-pricing-json-editor'
/>
<Button style={{ marginTop: 12 }} onClick={applyJson}>
{t('应用 JSON')}
</Button>
</TabPane>
</Tabs>
</Space>
</TabPane>
<TabPane tab={t('展示价格')} itemKey='display'>
<Space vertical align='start' style={{ width: '100%' }}>
<Input
prefix={t('模型')}
value={name}
onChange={setName}
style={{ width: 260 }}
disabled={Boolean(modelName)}
data-testid='display-pricing-model-name'
/>
</TabPane>
<TabPane tab={t('预览')} itemKey='preview'>
<PreviewPanel config={draft} />
</TabPane>
<TabPane tab={t('JSON')} itemKey='json'>
<TextArea
value={jsonText}
onChange={setJsonText}
autosize={{ minRows: 18, maxRows: 28 }}
data-testid='model-pricing-json-editor'
<DisplayPricingForm
value={displayPricingItemsDraft}
onChange={(value) => {
setDisplayPricingItemsDraft(value);
setDisplayPricingDirty(true);
}}
/>
<Button style={{ marginTop: 12 }} onClick={applyJson}>
{t('应用 JSON')}
</Button>
</TabPane>
</Tabs>
</Space>
</Space>
</TabPane>
</Tabs>
<GeneratorModal
visible={generatorVisible}
dimensions={draft.dimensions || []}


+ 83
- 0
web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.js Ver ficheiro

@@ -28,6 +28,84 @@ export const createDefaultDisplayPricingItems = () => [
export const formatDisplayPricingJson = (items) =>
JSON.stringify(Array.isArray(items) ? items : [], null, 2);

export const getEditableDisplayPricingItems = (rules, modelName) => {
if (!modelName) return [];
const items = rules?.[modelName];
return Array.isArray(items) ? items : createDefaultDisplayPricingItems();
};

export const shouldSaveBillingConfig = ({
isNewModel,
hasExistingBillingConfig,
billingDirty,
}) =>
Boolean(isNewModel) ||
Boolean(hasExistingBillingConfig) ||
Boolean(billingDirty);

export const createDisplayPricingRow = () => ({
specification: '',
official_supplier_tip: '',
price: 0,
unit: '',
sort_order: 0,
discount_rate: 1,
});

export const normalizeDisplayPricingFormItems = (items) =>
(Array.isArray(items) ? items : []).map((item) => ({
specification: String(item?.specification || '').trim(),
official_supplier_tip: String(item?.official_supplier_tip || '').trim(),
price: toFiniteNumber(item?.price, 0),
unit: String(item?.unit || '').trim(),
sort_order: toFiniteNumber(item?.sort_order, 0),
discount_rate: toFiniteNumber(item?.discount_rate, 1),
}));

export const validateDisplayPricingFormItems = (items) => {
const normalized = normalizeDisplayPricingFormItems(items).filter(
(item) =>
item.specification ||
item.official_supplier_tip ||
item.price > 0 ||
item.unit,
);

for (let index = 0; index < normalized.length; index++) {
const item = normalized[index];
if (!item.specification) {
return {
ok: false,
error: `row ${index + 1}: specification is required`,
items: [],
};
}
if (!item.unit) {
return {
ok: false,
error: `row ${index + 1}: unit is required`,
items: [],
};
}
if (item.price < 0) {
return {
ok: false,
error: `row ${index + 1}: price must be >= 0`,
items: [],
};
}
if (item.discount_rate <= 0 || item.discount_rate > 1) {
return {
ok: false,
error: `row ${index + 1}: discount_rate must be > 0 and <= 1`,
items: [],
};
}
}

return { ok: true, items: normalized };
};

export const parseDisplayPricingJson = (text) => {
let parsed;
try {
@@ -59,3 +137,8 @@ export const normalizeDisplayPricingItems = (items) =>
String(right?.specification || ''),
);
});

const toFiniteNumber = (value, fallback) => {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
};

+ 128
- 0
web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.test.js Ver ficheiro

@@ -2,6 +2,10 @@ import { describe, expect, test } from 'bun:test';
import {
createDefaultDisplayPricingItems,
formatDisplayPricingJson,
getEditableDisplayPricingItems,
normalizeDisplayPricingFormItems,
shouldSaveBillingConfig,
validateDisplayPricingFormItems,
parseDisplayPricingJson,
} from './displayPricingConfig';

@@ -46,4 +50,128 @@ describe('displayPricingConfig utilities', () => {
expect(text).toContain('"specification": "768p-6s"');
expect(text).toContain('"unit": "second"');
});

test('returns existing model display pricing or default editable items', () => {
const rules = {
'video-model': [
{
specification: '1080p',
price: 0.5,
unit: 'second',
sort_order: 2,
},
],
};

expect(getEditableDisplayPricingItems(rules, 'video-model')).toEqual(
rules['video-model'],
);
expect(getEditableDisplayPricingItems(rules, 'missing-model')).toEqual(
createDefaultDisplayPricingItems(),
);
expect(getEditableDisplayPricingItems(rules, '')).toEqual([]);
});

test('normalizes structured display pricing form rows for saving', () => {
const items = normalizeDisplayPricingFormItems([
{
specification: ' 1080p ',
official_supplier_tip: ' supplier ',
price: '0.5',
unit: ' second ',
sort_order: '2',
discount_rate: '0.8',
},
{
specification: '',
price: 'not-a-number',
},
]);

expect(items).toEqual([
{
specification: '1080p',
official_supplier_tip: 'supplier',
price: 0.5,
unit: 'second',
sort_order: 2,
discount_rate: 0.8,
},
{
specification: '',
official_supplier_tip: '',
price: 0,
unit: '',
sort_order: 0,
discount_rate: 1,
},
]);
});

test('validates display pricing form rows and ignores empty rows', () => {
const empty = validateDisplayPricingFormItems([
{ specification: '', price: 0, unit: '' },
]);
expect(empty.ok).toBe(true);
expect(empty.items).toEqual([]);

const invalid = validateDisplayPricingFormItems([
{ specification: '768p', price: 1, unit: '' },
]);
expect(invalid.ok).toBe(false);
expect(invalid.error).toContain('unit');

const valid = validateDisplayPricingFormItems([
{ specification: '768p', price: '1', unit: 'second' },
]);
expect(valid.ok).toBe(true);
expect(valid.items).toEqual([
{
specification: '768p',
official_supplier_tip: '',
price: 1,
unit: 'second',
sort_order: 0,
discount_rate: 1,
},
]);
});

test('skips billing save when only display pricing changed for a legacy model', () => {
expect(
shouldSaveBillingConfig({
isNewModel: false,
hasExistingBillingConfig: false,
billingDirty: false,
displayPricingDirty: true,
}),
).toBe(false);
});

test('saves billing config for existing rules, billing edits, or new model creation', () => {
expect(
shouldSaveBillingConfig({
isNewModel: false,
hasExistingBillingConfig: true,
billingDirty: false,
displayPricingDirty: true,
}),
).toBe(true);
expect(
shouldSaveBillingConfig({
isNewModel: false,
hasExistingBillingConfig: false,
billingDirty: true,
displayPricingDirty: true,
}),
).toBe(true);
expect(
shouldSaveBillingConfig({
isNewModel: true,
hasExistingBillingConfig: false,
billingDirty: false,
displayPricingDirty: false,
}),
).toBe(true);
});
});

+ 61
- 49
web/src/pages/Setting/Operation/ModelPricing/index.jsx Ver ficheiro

@@ -6,20 +6,19 @@ import {
Popconfirm,
Space,
Table,
TabPane,
Tabs,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { Edit, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../../../helpers';
import DisplayPricing from './DisplayPricing';
import Editor from './Editor';
import { normalizeDisplayPricingItems } from './displayPricingConfig';

export default function ModelPricing() {
const { t } = useTranslation();
const [rules, setRules] = useState({});
const [displayRules, setDisplayRules] = useState({});
const [legacyPrices, setLegacyPrices] = useState({});
const [keyword, setKeyword] = useState('');
const [editingModel, setEditingModel] = useState(null);
@@ -28,8 +27,9 @@ export default function ModelPricing() {
const refresh = async () => {
setLoading(true);
try {
const [rulesRes, optionsRes] = await Promise.all([
const [rulesRes, displayRes, optionsRes] = await Promise.all([
API.get('/api/option/model_pricing'),
API.get('/api/option/model_display_pricing'),
API.get('/api/option/'),
]);
if (rulesRes.data?.success) {
@@ -37,6 +37,11 @@ export default function ModelPricing() {
} else {
showError(rulesRes.data?.message || t('加载模型多维计费失败'));
}
if (displayRes.data?.success) {
setDisplayRules(displayRes.data.data || {});
} else {
showError(displayRes.data?.message || t('加载展示价格失败'));
}
if (optionsRes.data?.success) {
const modelPrice =
optionsRes.data.data?.find((item) => item.key === 'ModelPrice')
@@ -58,22 +63,26 @@ export default function ModelPricing() {
const names = new Set([
...Object.keys(legacyPrices),
...Object.keys(rules),
...Object.keys(displayRules),
]);
return [...names]
.filter((name) => name.toLowerCase().includes(keyword.toLowerCase()))
.sort()
.map((name) => {
const cfg = rules[name];
const displayItems = normalizeDisplayPricingItems(displayRules[name]);
return {
model: name,
configured: Boolean(cfg),
displayConfigured: displayItems.length > 0,
displayCount: displayItems.length,
dimensions: cfg?.dimensions?.length || 0,
rows: cfg?.table?.length || 0,
billingUnit: cfg?.billing_unit || '-',
legacyPrice: legacyPrices[name],
};
});
}, [rules, legacyPrices, keyword]);
}, [rules, displayRules, legacyPrices, keyword]);

const remove = async (model) => {
try {
@@ -106,6 +115,16 @@ export default function ModelPricing() {
{ title: t('维度'), dataIndex: 'dimensions', width: 90 },
{ title: t('行数'), dataIndex: 'rows', width: 90 },
{ title: t('计费单位'), dataIndex: 'billingUnit', width: 150 },
{
title: t('展示价格'),
dataIndex: 'displayCount',
width: 120,
render: (count, row) => (
<Tag color={row.displayConfigured ? 'green' : 'grey'}>
{row.displayConfigured ? `${count} ${t('项')}` : t('未配置')}
</Tag>
),
},
{
title: t('Legacy 价格'),
dataIndex: 'legacyPrice',
@@ -144,56 +163,49 @@ export default function ModelPricing() {

return (
<Card style={{ marginTop: 10 }} data-testid='model-pricing-page'>
<Tabs type='line'>
<TabPane tab={t('真实计费')} itemKey='billing'>
<Space vertical align='start' style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Title heading={5}>
{t('模型多维计费')}
</Typography.Title>
<Space>
<Button
icon={<RefreshCw size={16} />}
onClick={refresh}
loading={loading}
>
{t('刷新')}
</Button>
<Button
icon={<Plus size={16} />}
type='primary'
data-testid='model-pricing-add-model'
onClick={() => setEditingModel('')}
>
{t('添加模型')}
</Button>
</Space>
</Space>
<Input
placeholder={t('搜索模型')}
value={keyword}
onChange={setKeyword}
style={{ width: 280 }}
data-testid='model-pricing-search'
/>
<Table
columns={columns}
dataSource={data}
rowKey='model'
pagination={{ pageSize: 10 }}
<Space vertical align='start' style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Title heading={5}>{t('模型多维计费')}</Typography.Title>
<Space>
<Button
icon={<RefreshCw size={16} />}
onClick={refresh}
loading={loading}
data-testid='model-pricing-table'
/>
>
{t('刷新')}
</Button>
<Button
icon={<Plus size={16} />}
type='primary'
data-testid='model-pricing-add-model'
onClick={() => setEditingModel('')}
>
{t('添加模型')}
</Button>
</Space>
</TabPane>
<TabPane tab={t('展示价格')} itemKey='display'>
<DisplayPricing />
</TabPane>
</Tabs>
</Space>
<Input
placeholder={t('搜索模型')}
value={keyword}
onChange={setKeyword}
style={{ width: 280 }}
data-testid='model-pricing-search'
/>
<Table
columns={columns}
dataSource={data}
rowKey='model'
pagination={{ pageSize: 10 }}
loading={loading}
data-testid='model-pricing-table'
/>
</Space>
<Editor
visible={editingModel !== null}
modelName={editingModel || ''}
config={editingModel ? rules[editingModel] : null}
hasExistingBillingConfig={Boolean(editingModel && rules[editingModel])}
displayPricingItems={editingModel ? displayRules[editingModel] : []}
onClose={() => setEditingModel(null)}
onSaved={refresh}
/>


Carregando…
Cancelar
Guardar