- 避免仅编辑展示价格时写入默认真实计费规则 - 将 video-extend 纳入 KlingAiping 原生渠道约束 - 补充相关保存决策、渠道约束和计费展示测试 Co-Authored-By: Codex <noreply@anthropic.com>master
| @@ -28,6 +28,12 @@ CLAUDE.md | |||||
| logs/ | logs/ | ||||
| docs/superpowers | docs/superpowers | ||||
| # Runtime request/response capture files | |||||
| /[0-9]*.json | |||||
| # Local dev server logs | |||||
| web/vite-*.log | |||||
| # E2E / Playwright artifacts | # E2E / Playwright artifacts | ||||
| test-artifacts/ | test-artifacts/ | ||||
| web/.playwright/ | web/.playwright/ | ||||
| @@ -11,6 +11,7 @@ import ( | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/constant" | "github.com/QuantumNous/new-api/constant" | ||||
| "github.com/QuantumNous/new-api/dto" | "github.com/QuantumNous/new-api/dto" | ||||
| "github.com/QuantumNous/new-api/logger" | |||||
| "github.com/QuantumNous/new-api/middleware" | "github.com/QuantumNous/new-api/middleware" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| "github.com/QuantumNous/new-api/relay" | "github.com/QuantumNous/new-api/relay" | ||||
| @@ -41,9 +42,32 @@ func KlingAipingNativeTaskSubmit(c *gin.Context) { | |||||
| configureKlingAipingTaskRelayInfo(c, relayInfo, route, payload) | configureKlingAipingTaskRelayInfo(c, relayInfo, route, payload) | ||||
| if bound := resolveKlingAipingBoundChannel(c, relayInfo); bound != nil { | |||||
| relayInfo.LockedChannel = bound | |||||
| } | |||||
| relayTaskWithInfo(c, relayInfo) | 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) { | func configureKlingAipingTaskRelayInfo(c *gin.Context, relayInfo *relaycommon.RelayInfo, route klingaiping.Route, payload map[string]any) { | ||||
| // Native Kling routes bypass Distribute(), so force relayTaskWithInfo to | // Native Kling routes bypass Distribute(), so force relayTaskWithInfo to | ||||
| // select a channel instead of reading a preselected one from context. | // 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"}) | c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "route not found"}) | ||||
| return | 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 { | if setupErr := middleware.SetupContextForSelectedChannel(c, channel, route.BillingModel); setupErr != nil { | ||||
| c.JSON(setupErr.StatusCode, gin.H{"code": setupErr.GetErrorCode(), "message": setupErr.Error()}) | c.JSON(setupErr.StatusCode, gin.H{"code": setupErr.GetErrorCode(), "message": setupErr.Error()}) | ||||
| @@ -133,6 +162,25 @@ func KlingAipingNativeProxy(c *gin.Context) { | |||||
| copyProxyResponse(c, resp) | 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) { | func readJSONPayload(c *gin.Context) (map[string]any, error) { | ||||
| body, err := common.GetBodyStorage(c) | body, err := common.GetBodyStorage(c) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -358,23 +406,7 @@ func proxyBodyBytes(c *gin.Context, route klingaiping.Route) ([]byte, error) { | |||||
| if err != nil { | if err != nil { | ||||
| return nil, err | 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) { | func copyProxyResponse(c *gin.Context, resp *http.Response) { | ||||
| @@ -45,13 +45,13 @@ func TestKlingAipingSubmitPreparationUsesModelNameBeforeModel(t *testing.T) { | |||||
| } | } | ||||
| func TestKlingAipingSubmitPreparationDoesNotLockChannel(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_url":"https://example.com/voice.mp3", | ||||
| "voice_name":"voice" | "voice_name":"voice" | ||||
| }`) | }`) | ||||
| payload, err := readJSONPayload(c) | payload, err := readJSONPayload(c) | ||||
| require.NoError(t, err) | 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) | require.True(t, ok) | ||||
| info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}} | info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}} | ||||
| info.OriginModelName = resolveKlingAipingModel(payload, route) | info.OriginModelName = resolveKlingAipingModel(payload, route) | ||||
| @@ -88,6 +88,10 @@ func TestRequiredTaskChannelTypeOnlyMatchesKlingAipingNativePaths(t *testing.T) | |||||
| c.Request.URL.Path = "/v1/videos/text2video" | c.Request.URL.Path = "/v1/videos/text2video" | ||||
| require.Equal(t, constant.ChannelTypeKlingAiping, requiredTaskChannelTypeForRequest(c)) | 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 = newControllerJSONContext(t, "/v1/videos/video_123/remix", `{}`) | ||||
| c.Request.URL.Path = "/v1/videos/video_123/remix" | c.Request.URL.Path = "/v1/videos/video_123/remix" | ||||
| require.Zero(t, requiredTaskChannelTypeForRequest(c)) | require.Zero(t, requiredTaskChannelTypeForRequest(c)) | ||||
| @@ -117,20 +121,6 @@ func TestKlingAipingTaskDataObjectUsesPublicTaskIDAndWatermarkURL(t *testing.T) | |||||
| require.Equal(t, "", videos[0].(map[string]any)["watermark_url"]) | 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) { | func TestDoKlingAipingProxyRequestUsesSelectedContextKey(t *testing.T) { | ||||
| service.InitHttpClient() | service.InitHttpClient() | ||||
| var gotAuth string | var gotAuth string | ||||
| @@ -141,9 +131,9 @@ func TestDoKlingAipingProxyRequestUsesSelectedContextKey(t *testing.T) { | |||||
| })) | })) | ||||
| defer server.Close() | defer server.Close() | ||||
| c := newControllerJSONContext(t, "/v1/elements/custom", ``) | |||||
| c := newControllerJSONContext(t, "/v1/general/advanced-presets-elements", ``) | |||||
| common.SetContextKey(c, constant.ContextKeyChannelKey, "selected-key") | 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) | require.True(t, ok) | ||||
| channel := &model.Channel{ | channel := &model.Channel{ | ||||
| Key: "raw-channel-key", | Key: "raw-channel-key", | ||||
| @@ -2,6 +2,7 @@ package controller | |||||
| import ( | import ( | ||||
| "net/http" | "net/http" | ||||
| "strings" | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| @@ -15,7 +16,7 @@ func GetModelPricingRules(c *gin.Context) { | |||||
| } | } | ||||
| func GetModelPricingRule(c *gin.Context) { | func GetModelPricingRule(c *gin.Context) { | ||||
| modelName := c.Param("model") | |||||
| modelName := strings.TrimPrefix(c.Param("model"), "/") | |||||
| cfg := ratio_setting.GetPricingConfig(modelName) | cfg := ratio_setting.GetPricingConfig(modelName) | ||||
| if cfg == nil { | if cfg == nil { | ||||
| c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "model pricing rule not found"}) | 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) { | func UpdateModelPricingRule(c *gin.Context) { | ||||
| modelName := c.Param("model") | |||||
| modelName := strings.TrimPrefix(c.Param("model"), "/") | |||||
| var cfg types.PricingConfig | var cfg types.PricingConfig | ||||
| if err := common.DecodeJson(c.Request.Body, &cfg); err != nil { | if err := common.DecodeJson(c.Request.Body, &cfg); err != nil { | ||||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) | 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) { | func DeleteModelPricingRule(c *gin.Context) { | ||||
| modelName := c.Param("model") | |||||
| modelName := strings.TrimPrefix(c.Param("model"), "/") | |||||
| if err := ratio_setting.DeletePricingConfig(modelName); err != nil { | if err := ratio_setting.DeletePricingConfig(modelName); err != nil { | ||||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) | c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) | ||||
| return | return | ||||
| @@ -49,9 +49,9 @@ func setupModelPricingControllerTest(t *testing.T) *gin.Engine { | |||||
| r := gin.New() | r := gin.New() | ||||
| r.GET("/api/option/model_pricing", GetModelPricingRules) | 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 | return r | ||||
| } | } | ||||
| @@ -94,6 +94,24 @@ func TestModelPricingController_PutRejectsBadConfig(t *testing.T) { | |||||
| assert.Contains(t, w.Body.String(), "table must not be empty") | 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 { | func performJSONRequest(t *testing.T, r *gin.Engine, method, path string, body any) *httptest.ResponseRecorder { | ||||
| t.Helper() | t.Helper() | ||||
| var payload []byte | var payload []byte | ||||
| @@ -183,6 +183,44 @@ func TestGetUserPricingIncludesDisplayPricingWithoutChangingComputedPrice(t *tes | |||||
| require.NotEqual(t, float64(0.33333), resp["original_price"]) | 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,无个人倍率) | // TestGetUserPricing_LoggedIn_NoDiscount 已登录但无折扣(分组倍率=1,无个人倍率) | ||||
| func TestGetUserPricing_LoggedIn_NoDiscount(t *testing.T) { | func TestGetUserPricing_LoggedIn_NoDiscount(t *testing.T) { | ||||
| db := setupPricingTestDB(t) | db := setupPricingTestDB(t) | ||||
| @@ -296,7 +296,7 @@ func fastTokenCountMetaForPricing(request dto.Request) *types.TokenCountMeta { | |||||
| return meta | return meta | ||||
| } | } | ||||
| func concreteDoubaoVideoBindingGroup(c *gin.Context, group string) string { | |||||
| func concreteTaskVideoBindingGroup(c *gin.Context, group string) string { | |||||
| group = strings.TrimSpace(group) | group = strings.TrimSpace(group) | ||||
| if group == "" { | if group == "" { | ||||
| group = strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyTokenGroup)) | group = strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyTokenGroup)) | ||||
| @@ -311,15 +311,24 @@ func concreteDoubaoVideoBindingGroup(c *gin.Context, group string) string { | |||||
| return group | 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) | group = strings.TrimSpace(group) | ||||
| if group == "" || group == "auto" { | |||||
| if group == "" || group == "auto" || channel == nil { | |||||
| return 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) { | 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"), | Type: c.GetInt("channel_type"), | ||||
| Name: c.GetString("channel_name"), | Name: c.GetString("channel_name"), | ||||
| AutoBan: &autoBanInt, | AutoBan: &autoBanInt, | ||||
| }, concreteDoubaoVideoBindingGroup(c, info.TokenGroup), nil | |||||
| }, concreteTaskVideoBindingGroup(c, info.TokenGroup), nil | |||||
| } | } | ||||
| channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam) | 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 { | if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil { | ||||
| channel = lockedCh | channel = lockedCh | ||||
| selectedGroup = concreteDoubaoVideoBindingGroup(c, relayInfo.TokenGroup) | |||||
| selectedGroup = concreteTaskVideoBindingGroup(c, relayInfo.TokenGroup) | |||||
| if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil { | if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil { | ||||
| taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError) | taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError) | ||||
| break | 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 | break | ||||
| } | } | ||||
| @@ -768,9 +777,9 @@ func isKlingAipingNativePath(path string) bool { | |||||
| "/v1/videos/motion-control", | "/v1/videos/motion-control", | ||||
| "/v1/videos/omni-video", | "/v1/videos/omni-video", | ||||
| "/v1/videos/multi-image2video", | "/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", | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -216,7 +216,7 @@ func normalizeRequestBody(body map[string]any, info *relaycommon.RelayInfo) { | |||||
| modelName = info.UpstreamModelName | modelName = info.UpstreamModelName | ||||
| } | } | ||||
| if modelName != "" { | if modelName != "" { | ||||
| body["model_name"] = modelName | |||||
| body["model_name"] = canonicalAipingModelName(modelName) | |||||
| } | } | ||||
| delete(body, "model") | delete(body, "model") | ||||
| @@ -316,6 +316,26 @@ func stringValue(v any) string { | |||||
| return "" | 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 { | func firstString(m map[string]any, keys ...string) string { | ||||
| for _, key := range keys { | for _, key := range keys { | ||||
| if s := stringValue(m[key]); s != "" { | if s := stringValue(m[key]); s != "" { | ||||
| @@ -17,29 +17,38 @@ func TestRoutesUseOfficialExternalAndAipingUpstreamPaths(t *testing.T) { | |||||
| route, ok := FindRoute(http.MethodPost, "/v1/videos/text2video", RouteKindSubmit) | route, ok := FindRoute(http.MethodPost, "/v1/videos/text2video", RouteKindSubmit) | ||||
| require.True(t, ok) | require.True(t, ok) | ||||
| require.Equal(t, ActionText2Video, route.Action) | 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.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.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.True(t, ok) | ||||
| require.Equal(t, ActionDeleteVoices, deleteVoices.Action) | 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.True(t, ok) | ||||
| require.Equal(t, ActionDeleteElements, deleteElements.Action) | 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) { | func TestBuildRequestURLUsesAipingKlingPath(t *testing.T) { | ||||
| adaptor := &TaskAdaptor{} | adaptor := &TaskAdaptor{} | ||||
| info := &relaycommon.RelayInfo{ | info := &relaycommon.RelayInfo{ | ||||
| ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://aiping.cn/api"}, | |||||
| ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://aiping.cn"}, | |||||
| TaskRelayInfo: &relaycommon.TaskRelayInfo{ | TaskRelayInfo: &relaycommon.TaskRelayInfo{ | ||||
| Action: ActionText2Video, | Action: ActionText2Video, | ||||
| }, | }, | ||||
| @@ -48,7 +57,7 @@ func TestBuildRequestURLUsesAipingKlingPath(t *testing.T) { | |||||
| url, err := adaptor.BuildRequestURL(info) | url, err := adaptor.BuildRequestURL(info) | ||||
| require.NoError(t, err) | 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) { | func TestFetchTaskUsesSingleTaskPath(t *testing.T) { | ||||
| @@ -68,7 +77,7 @@ func TestFetchTaskUsesSingleTaskPath(t *testing.T) { | |||||
| require.NoError(t, err) | require.NoError(t, err) | ||||
| defer resp.Body.Close() | 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) { | func TestBuildRequestBodyNormalizesOfficialCompatibleFields(t *testing.T) { | ||||
| @@ -124,6 +133,35 @@ func TestBuildRequestBodyNormalizesOfficialCompatibleFields(t *testing.T) { | |||||
| }, got["image_list"]) | }, 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) { | func TestValidateRequestRejectsOmniModelOnTextOrImageRoutes(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| c, _ := gin.CreateTestContext(nil) | c, _ := gin.CreateTestContext(nil) | ||||
| @@ -8,7 +8,7 @@ const ( | |||||
| ActionMotionControl = "kling-motion-control" | ActionMotionControl = "kling-motion-control" | ||||
| ActionOmniVideo = "kling-omni-video" | ActionOmniVideo = "kling-omni-video" | ||||
| ActionMultiImage2Video = "kling-multi-image2video" | ActionMultiImage2Video = "kling-multi-image2video" | ||||
| ActionMultiElements = "kling-multi-elements" | |||||
| ActionVideoExtend = "kling-video-extend" | |||||
| ActionAdvancedElements = "kling-advanced-elements-create" | ActionAdvancedElements = "kling-advanced-elements-create" | ||||
| ActionVoicesCreate = "kling-voices-create" | ActionVoicesCreate = "kling-voices-create" | ||||
| ActionDeleteElements = "kling-delete-elements" | ActionDeleteElements = "kling-delete-elements" | ||||
| @@ -23,7 +23,6 @@ const ( | |||||
| ) | ) | ||||
| const ( | const ( | ||||
| ModelKlingElements = "kling-elements" | |||||
| ModelKlingAdvancedElements = "kling-advanced-elements" | ModelKlingAdvancedElements = "kling-advanced-elements" | ||||
| ModelCustomVoices = "custom-voices" | ModelCustomVoices = "custom-voices" | ||||
| ) | ) | ||||
| @@ -38,48 +37,48 @@ type Route struct { | |||||
| } | } | ||||
| var Routes = []Route{ | 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) { | func FindRoute(method, pathPattern, kind string) (Route, bool) { | ||||
| @@ -117,7 +116,6 @@ func Models() []string { | |||||
| "kling-v3", | "kling-v3", | ||||
| "kling-video-o1", | "kling-video-o1", | ||||
| "kling-v3-omni", | "kling-v3-omni", | ||||
| ModelKlingElements, | |||||
| ModelKlingAdvancedElements, | ModelKlingAdvancedElements, | ||||
| ModelCustomVoices, | ModelCustomVoices, | ||||
| } | } | ||||
| @@ -192,9 +192,9 @@ func SetApiRouter(router *gin.Engine) { | |||||
| optionRoute.GET("/", controller.GetOptions) | optionRoute.GET("/", controller.GetOptions) | ||||
| optionRoute.PUT("/", controller.UpdateOption) | optionRoute.PUT("/", controller.UpdateOption) | ||||
| optionRoute.GET("/model_pricing", controller.GetModelPricingRules) | 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", controller.GetModelDisplayPricingRules) | ||||
| optionRoute.GET("/model_display_pricing/*model", controller.GetModelDisplayPricingRule) | optionRoute.GET("/model_display_pricing/*model", controller.GetModelDisplayPricingRule) | ||||
| optionRoute.PUT("/model_display_pricing/*model", controller.UpdateModelDisplayPricingRule) | optionRoute.PUT("/model_display_pricing/*model", controller.UpdateModelDisplayPricingRule) | ||||
| @@ -25,12 +25,6 @@ func SetVideoRouter(router *gin.Engine) { | |||||
| klingAipingRouter := router.Group("/v1") | klingAipingRouter := router.Group("/v1") | ||||
| klingAipingRouter.Use(middleware.TokenAuth()) | 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.POST("/videos/text2video", controller.KlingAipingNativeTaskSubmit) | ||||
| klingAipingRouter.GET("/videos/text2video/:task_id", controller.KlingAipingNativeTaskFetch) | klingAipingRouter.GET("/videos/text2video/:task_id", controller.KlingAipingNativeTaskFetch) | ||||
| klingAipingRouter.GET("/videos/text2video", controller.KlingAipingNativeTaskList) | klingAipingRouter.GET("/videos/text2video", controller.KlingAipingNativeTaskList) | ||||
| @@ -46,26 +40,20 @@ func SetVideoRouter(router *gin.Engine) { | |||||
| klingAipingRouter.POST("/videos/multi-image2video", controller.KlingAipingNativeTaskSubmit) | klingAipingRouter.POST("/videos/multi-image2video", controller.KlingAipingNativeTaskSubmit) | ||||
| klingAipingRouter.GET("/videos/multi-image2video/:task_id", controller.KlingAipingNativeTaskFetch) | klingAipingRouter.GET("/videos/multi-image2video/:task_id", controller.KlingAipingNativeTaskFetch) | ||||
| klingAipingRouter.GET("/videos/multi-image2video", controller.KlingAipingNativeTaskList) | 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 | // openai compatible API video routes | ||||
| @@ -17,12 +17,12 @@ func TestKlingAipingNativeRoutesDoNotFallThroughToOpenAIVideoRoutes(t *testing.T | |||||
| requireHandlerContains(t, r, http.MethodPost, "/v1/videos/text2video", "KlingAipingNativeTaskSubmit") | 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/:task_id", "KlingAipingNativeTaskFetch") | ||||
| requireHandlerContains(t, r, http.MethodGet, "/v1/videos/text2video", "KlingAipingNativeTaskList") | 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.MethodPost, "/v1/videos", "RelayTask") | ||||
| requireHandlerContains(t, r, http.MethodGet, "/v1/videos/:task_id", "RelayTaskFetch") | requireHandlerContains(t, r, http.MethodGet, "/v1/videos/:task_id", "RelayTaskFetch") | ||||
| @@ -11,7 +11,7 @@ | |||||
| set -euo pipefail | set -euo pipefail | ||||
| DOCKER_REGISTRY="registry.cn-hangzhou.aliyuncs.com/fengsilin/new-api" | 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" | CN_DIR="/root/new-api-cn" | ||||
| OV_DIR="/root/new-api-ov" | OV_DIR="/root/new-api-ov" | ||||
| @@ -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) | |||||
| } | |||||
| @@ -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")) | |||||
| } | |||||
| @@ -3,13 +3,17 @@ package service | |||||
| import ( | import ( | ||||
| "context" | "context" | ||||
| "encoding/json" | "encoding/json" | ||||
| "io" | |||||
| "net/http" | "net/http" | ||||
| "net/http/httptest" | "net/http/httptest" | ||||
| "os" | "os" | ||||
| "strings" | |||||
| "testing" | "testing" | ||||
| "time" | "time" | ||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/constant" | |||||
| "github.com/QuantumNous/new-api/dto" | |||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | relaycommon "github.com/QuantumNous/new-api/relay/common" | ||||
| "github.com/QuantumNous/new-api/types" | "github.com/QuantumNous/new-api/types" | ||||
| @@ -666,6 +670,76 @@ func TestCASGuardedSettle_Win(t *testing.T) { | |||||
| assert.Equal(t, actualQuota, task.Quota) | 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) { | func TestNonTerminalUpdate_NoBilling(t *testing.T) { | ||||
| truncate(t) | truncate(t) | ||||
| ctx := context.Background() | ctx := context.Background() | ||||
| @@ -713,6 +787,74 @@ func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.Task | |||||
| return m.adjustReturn | 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 | // PerCallBilling tests — settleTaskBillingOnComplete | ||||
| // =========================================================================== | // =========================================================================== | ||||
| @@ -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 任务 | // UpdateSunoTasks 按渠道更新所有 Suno 任务 | ||||
| func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error { | func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error { | ||||
| for channelId, taskIds := range taskChannelM { | for channelId, taskIds := range taskChannelM { | ||||
| @@ -171,22 +207,8 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM | |||||
| ch, err := model.CacheGetChannel(channelId) | ch, err := model.CacheGetChannel(channelId) | ||||
| if err != nil { | if err != nil { | ||||
| common.SysLog(fmt.Sprintf("CacheGetChannel: %v", err)) | 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) | adaptor := GetTaskAdaptorFunc(constant.TaskPlatformSuno) | ||||
| if adaptor == nil { | if adaptor == nil { | ||||
| @@ -227,6 +249,7 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM | |||||
| continue | continue | ||||
| } | } | ||||
| oldStatus := task.Status | |||||
| task.Status = lo.If(model.TaskStatus(responseItem.Status) != "", model.TaskStatus(responseItem.Status)).Else(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.FailReason = lo.If(responseItem.FailReason != "", responseItem.FailReason).Else(task.FailReason) | ||||
| task.SubmitTime = lo.If(responseItem.SubmitTime != 0, responseItem.SubmitTime).Else(task.SubmitTime) | 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) | task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime) | ||||
| if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure { | if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure { | ||||
| logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason) | 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 { | if responseItem.Status == model.TaskStatusSuccess { | ||||
| task.Progress = "100%" | task.Progress = "100%" | ||||
| @@ -305,21 +328,7 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann | |||||
| } | } | ||||
| cacheGetChannel, err := model.CacheGetChannel(channelId) | cacheGetChannel, err := model.CacheGetChannel(channelId) | ||||
| if err != nil { | 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) | return fmt.Errorf("CacheGetChannel failed: %w", err) | ||||
| } | } | ||||
| adaptor := GetTaskAdaptorFunc(platform) | adaptor := GetTaskAdaptorFunc(platform) | ||||
| @@ -6,6 +6,7 @@ const sectionStyle = { | |||||
| borderRadius: 6, | borderRadius: 6, | ||||
| background: 'var(--semi-color-bg-1)', | background: 'var(--semi-color-bg-1)', | ||||
| overflow: 'hidden', | overflow: 'hidden', | ||||
| minWidth: 0, | |||||
| }; | }; | ||||
| const headerStyle = { | const headerStyle = { | ||||
| @@ -20,7 +21,7 @@ const bodyStyle = { | |||||
| const gridStyle = { | const gridStyle = { | ||||
| display: 'grid', | display: 'grid', | ||||
| gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', | |||||
| gridTemplateColumns: 'repeat(auto-fit, minmax(min(280px, 100%), 1fr))', | |||||
| gap: '10px 16px', | gap: '10px 16px', | ||||
| }; | }; | ||||
| @@ -28,18 +29,45 @@ const itemStyle = { | |||||
| minWidth: 0, | minWidth: 0, | ||||
| }; | }; | ||||
| const fullWidthItemStyle = { | |||||
| ...itemStyle, | |||||
| gridColumn: '1 / -1', | |||||
| }; | |||||
| const valueStyle = { | const valueStyle = { | ||||
| marginTop: 3, | marginTop: 3, | ||||
| lineHeight: 1.55, | lineHeight: 1.55, | ||||
| wordBreak: 'break-word', | 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 }) => ( | const DetailItem = ({ item }) => ( | ||||
| <div style={itemStyle}> | |||||
| <div style={item?.fullWidth ? fullWidthItemStyle : itemStyle}> | |||||
| <Typography.Text type='tertiary' size='small'> | <Typography.Text type='tertiary' size='small'> | ||||
| {item.key} | {item.key} | ||||
| </Typography.Text> | </Typography.Text> | ||||
| <div style={valueStyle}>{item.value}</div> | |||||
| <div style={item?.fullWidth ? fullWidthValueStyle : valueStyle}> | |||||
| {item.value} | |||||
| </div> | |||||
| </div> | </div> | ||||
| ); | ); | ||||
| @@ -64,8 +92,8 @@ const DetailSection = ({ section }) => { | |||||
| const UsageLogExpandedDetail = ({ detail }) => { | const UsageLogExpandedDetail = ({ detail }) => { | ||||
| if (detail?.sections?.length) { | if (detail?.sections?.length) { | ||||
| return ( | return ( | ||||
| <div style={{ padding: '12px 16px' }}> | |||||
| <div style={{ display: 'grid', gap: 12 }}> | |||||
| <div style={expandedContainerStyle}> | |||||
| <div style={sectionsGridStyle}> | |||||
| {detail.sections.map((section) => ( | {detail.sections.map((section) => ( | ||||
| <DetailSection key={section.title} section={section} /> | <DetailSection key={section.title} section={section} /> | ||||
| ))} | ))} | ||||
| @@ -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, | |||||
| }; | |||||
| } | |||||
| @@ -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); | |||||
| }); | |||||
| }); | |||||
| @@ -39,6 +39,17 @@ import { | |||||
| import { ITEMS_PER_PAGE } from '../../constants'; | import { ITEMS_PER_PAGE } from '../../constants'; | ||||
| import { useTableCompactMode } from '../common/useTableCompactMode'; | import { useTableCompactMode } from '../common/useTableCompactMode'; | ||||
| import { mergeTaskBillingLogs } from './taskBillingLogMerge'; | 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 = () => { | export const useLogsData = () => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| @@ -410,7 +421,7 @@ export const useLogsData = () => { | |||||
| addKnown('billing', t('花费'), renderQuota(log.quota, 6)); | addKnown('billing', t('花费'), renderQuota(log.quota, 6)); | ||||
| addKnown('billing', t('Prompt Tokens'), log.prompt_tokens); | addKnown('billing', t('Prompt Tokens'), log.prompt_tokens); | ||||
| addKnown('billing', t('Completion Tokens'), log.completion_tokens); | addKnown('billing', t('Completion Tokens'), log.completion_tokens); | ||||
| if (other?.model_price !== undefined) { | |||||
| if (shouldShowModelPrice(other?.model_price)) { | |||||
| const quotaPerUnit = parseFloat( | const quotaPerUnit = parseFloat( | ||||
| localStorage.getItem('quota_per_unit') || '500000', | localStorage.getItem('quota_per_unit') || '500000', | ||||
| ); | ); | ||||
| @@ -433,7 +444,7 @@ export const useLogsData = () => { | |||||
| if (other?.group_ratio !== undefined) { | if (other?.group_ratio !== undefined) { | ||||
| addKnown('billing', t('分组倍率'), other.group_ratio); | 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); | addKnown('billing', t('用户分组倍率'), other.user_group_ratio); | ||||
| } | } | ||||
| if (other?.billing_mode) { | if (other?.billing_mode) { | ||||
| @@ -469,6 +480,21 @@ export const useLogsData = () => { | |||||
| if (other?.total_tokens !== undefined) { | if (other?.total_tokens !== undefined) { | ||||
| addKnown('billing', t('总 Tokens'), other.total_tokens); | 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) { | if (other?.pricing_snapshot) { | ||||
| const snapshot = other.pricing_snapshot || {}; | const snapshot = other.pricing_snapshot || {}; | ||||
| @@ -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> | |||||
| ); | |||||
| } | |||||
| @@ -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> | |||||
| ); | |||||
| } | |||||
| @@ -17,6 +17,12 @@ import DimensionEditor from './DimensionEditor'; | |||||
| import GeneratorModal from './GeneratorModal'; | import GeneratorModal from './GeneratorModal'; | ||||
| import PreviewPanel from './PreviewPanel'; | import PreviewPanel from './PreviewPanel'; | ||||
| import PricingGrid from './PricingGrid'; | import PricingGrid from './PricingGrid'; | ||||
| import DisplayPricingForm from './DisplayPricingForm'; | |||||
| import { | |||||
| getEditableDisplayPricingItems, | |||||
| shouldSaveBillingConfig, | |||||
| validateDisplayPricingFormItems, | |||||
| } from './displayPricingConfig'; | |||||
| import { | import { | ||||
| BILLING_UNITS, | BILLING_UNITS, | ||||
| FALLBACKS, | FALLBACKS, | ||||
| @@ -28,6 +34,8 @@ export default function Editor({ | |||||
| visible, | visible, | ||||
| modelName, | modelName, | ||||
| config, | config, | ||||
| hasExistingBillingConfig, | |||||
| displayPricingItems, | |||||
| onClose, | onClose, | ||||
| onSaved, | onSaved, | ||||
| }) { | }) { | ||||
| @@ -35,6 +43,9 @@ export default function Editor({ | |||||
| const [name, setName] = useState(modelName || ''); | const [name, setName] = useState(modelName || ''); | ||||
| const [draft, setDraft] = useState(createDefaultConfig()); | const [draft, setDraft] = useState(createDefaultConfig()); | ||||
| const [jsonText, setJsonText] = useState(''); | const [jsonText, setJsonText] = useState(''); | ||||
| const [displayPricingItemsDraft, setDisplayPricingItemsDraft] = useState([]); | |||||
| const [displayPricingDirty, setDisplayPricingDirty] = useState(false); | |||||
| const [billingDirty, setBillingDirty] = useState(false); | |||||
| const [history, setHistory] = useState([]); | const [history, setHistory] = useState([]); | ||||
| const [future, setFuture] = useState([]); | const [future, setFuture] = useState([]); | ||||
| const [generatorVisible, setGeneratorVisible] = useState(false); | const [generatorVisible, setGeneratorVisible] = useState(false); | ||||
| @@ -46,15 +57,24 @@ export default function Editor({ | |||||
| setName(modelName || ''); | setName(modelName || ''); | ||||
| setDraft(next); | setDraft(next); | ||||
| setJsonText(JSON.stringify(next, null, 2)); | setJsonText(JSON.stringify(next, null, 2)); | ||||
| setDisplayPricingItemsDraft( | |||||
| getEditableDisplayPricingItems( | |||||
| modelName ? { [modelName]: displayPricingItems } : {}, | |||||
| modelName, | |||||
| ), | |||||
| ); | |||||
| setDisplayPricingDirty(false); | |||||
| setBillingDirty(false); | |||||
| setHistory([]); | setHistory([]); | ||||
| setFuture([]); | setFuture([]); | ||||
| }, [visible, modelName, config]); | |||||
| }, [visible, modelName, config, displayPricingItems]); | |||||
| const validation = useMemo(() => validatePricingConfig(draft), [draft]); | const validation = useMemo(() => validatePricingConfig(draft), [draft]); | ||||
| const updateDraft = (next) => { | const updateDraft = (next) => { | ||||
| setHistory((items) => [...items, draft]); | setHistory((items) => [...items, draft]); | ||||
| setFuture([]); | setFuture([]); | ||||
| setBillingDirty(true); | |||||
| setDraft(next); | setDraft(next); | ||||
| setJsonText(JSON.stringify(next, null, 2)); | setJsonText(JSON.stringify(next, null, 2)); | ||||
| }; | }; | ||||
| @@ -95,15 +115,41 @@ export default function Editor({ | |||||
| showError(validation.errors.join('\n')); | showError(validation.errors.join('\n')); | ||||
| return; | 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); | setSaving(true); | ||||
| try { | 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('模型多维计费已保存')); | showSuccess(t('模型多维计费已保存')); | ||||
| await onSaved?.(); | await onSaved?.(); | ||||
| @@ -156,88 +202,115 @@ export default function Editor({ | |||||
| </Space> | </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 | <GeneratorModal | ||||
| visible={generatorVisible} | visible={generatorVisible} | ||||
| dimensions={draft.dimensions || []} | dimensions={draft.dimensions || []} | ||||
| @@ -28,6 +28,84 @@ export const createDefaultDisplayPricingItems = () => [ | |||||
| export const formatDisplayPricingJson = (items) => | export const formatDisplayPricingJson = (items) => | ||||
| JSON.stringify(Array.isArray(items) ? items : [], null, 2); | 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) => { | export const parseDisplayPricingJson = (text) => { | ||||
| let parsed; | let parsed; | ||||
| try { | try { | ||||
| @@ -59,3 +137,8 @@ export const normalizeDisplayPricingItems = (items) => | |||||
| String(right?.specification || ''), | String(right?.specification || ''), | ||||
| ); | ); | ||||
| }); | }); | ||||
| const toFiniteNumber = (value, fallback) => { | |||||
| const number = Number(value); | |||||
| return Number.isFinite(number) ? number : fallback; | |||||
| }; | |||||
| @@ -2,6 +2,10 @@ import { describe, expect, test } from 'bun:test'; | |||||
| import { | import { | ||||
| createDefaultDisplayPricingItems, | createDefaultDisplayPricingItems, | ||||
| formatDisplayPricingJson, | formatDisplayPricingJson, | ||||
| getEditableDisplayPricingItems, | |||||
| normalizeDisplayPricingFormItems, | |||||
| shouldSaveBillingConfig, | |||||
| validateDisplayPricingFormItems, | |||||
| parseDisplayPricingJson, | parseDisplayPricingJson, | ||||
| } from './displayPricingConfig'; | } from './displayPricingConfig'; | ||||
| @@ -46,4 +50,128 @@ describe('displayPricingConfig utilities', () => { | |||||
| expect(text).toContain('"specification": "768p-6s"'); | expect(text).toContain('"specification": "768p-6s"'); | ||||
| expect(text).toContain('"unit": "second"'); | 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); | |||||
| }); | |||||
| }); | }); | ||||
| @@ -6,20 +6,19 @@ import { | |||||
| Popconfirm, | Popconfirm, | ||||
| Space, | Space, | ||||
| Table, | Table, | ||||
| TabPane, | |||||
| Tabs, | |||||
| Tag, | Tag, | ||||
| Typography, | Typography, | ||||
| } from '@douyinfe/semi-ui'; | } from '@douyinfe/semi-ui'; | ||||
| import { Edit, Plus, RefreshCw, Trash2 } from 'lucide-react'; | import { Edit, Plus, RefreshCw, Trash2 } from 'lucide-react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { API, showError, showSuccess } from '../../../../helpers'; | import { API, showError, showSuccess } from '../../../../helpers'; | ||||
| import DisplayPricing from './DisplayPricing'; | |||||
| import Editor from './Editor'; | import Editor from './Editor'; | ||||
| import { normalizeDisplayPricingItems } from './displayPricingConfig'; | |||||
| export default function ModelPricing() { | export default function ModelPricing() { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const [rules, setRules] = useState({}); | const [rules, setRules] = useState({}); | ||||
| const [displayRules, setDisplayRules] = useState({}); | |||||
| const [legacyPrices, setLegacyPrices] = useState({}); | const [legacyPrices, setLegacyPrices] = useState({}); | ||||
| const [keyword, setKeyword] = useState(''); | const [keyword, setKeyword] = useState(''); | ||||
| const [editingModel, setEditingModel] = useState(null); | const [editingModel, setEditingModel] = useState(null); | ||||
| @@ -28,8 +27,9 @@ export default function ModelPricing() { | |||||
| const refresh = async () => { | const refresh = async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| try { | 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_pricing'), | ||||
| API.get('/api/option/model_display_pricing'), | |||||
| API.get('/api/option/'), | API.get('/api/option/'), | ||||
| ]); | ]); | ||||
| if (rulesRes.data?.success) { | if (rulesRes.data?.success) { | ||||
| @@ -37,6 +37,11 @@ export default function ModelPricing() { | |||||
| } else { | } else { | ||||
| showError(rulesRes.data?.message || t('加载模型多维计费失败')); | showError(rulesRes.data?.message || t('加载模型多维计费失败')); | ||||
| } | } | ||||
| if (displayRes.data?.success) { | |||||
| setDisplayRules(displayRes.data.data || {}); | |||||
| } else { | |||||
| showError(displayRes.data?.message || t('加载展示价格失败')); | |||||
| } | |||||
| if (optionsRes.data?.success) { | if (optionsRes.data?.success) { | ||||
| const modelPrice = | const modelPrice = | ||||
| optionsRes.data.data?.find((item) => item.key === 'ModelPrice') | optionsRes.data.data?.find((item) => item.key === 'ModelPrice') | ||||
| @@ -58,22 +63,26 @@ export default function ModelPricing() { | |||||
| const names = new Set([ | const names = new Set([ | ||||
| ...Object.keys(legacyPrices), | ...Object.keys(legacyPrices), | ||||
| ...Object.keys(rules), | ...Object.keys(rules), | ||||
| ...Object.keys(displayRules), | |||||
| ]); | ]); | ||||
| return [...names] | return [...names] | ||||
| .filter((name) => name.toLowerCase().includes(keyword.toLowerCase())) | .filter((name) => name.toLowerCase().includes(keyword.toLowerCase())) | ||||
| .sort() | .sort() | ||||
| .map((name) => { | .map((name) => { | ||||
| const cfg = rules[name]; | const cfg = rules[name]; | ||||
| const displayItems = normalizeDisplayPricingItems(displayRules[name]); | |||||
| return { | return { | ||||
| model: name, | model: name, | ||||
| configured: Boolean(cfg), | configured: Boolean(cfg), | ||||
| displayConfigured: displayItems.length > 0, | |||||
| displayCount: displayItems.length, | |||||
| dimensions: cfg?.dimensions?.length || 0, | dimensions: cfg?.dimensions?.length || 0, | ||||
| rows: cfg?.table?.length || 0, | rows: cfg?.table?.length || 0, | ||||
| billingUnit: cfg?.billing_unit || '-', | billingUnit: cfg?.billing_unit || '-', | ||||
| legacyPrice: legacyPrices[name], | legacyPrice: legacyPrices[name], | ||||
| }; | }; | ||||
| }); | }); | ||||
| }, [rules, legacyPrices, keyword]); | |||||
| }, [rules, displayRules, legacyPrices, keyword]); | |||||
| const remove = async (model) => { | const remove = async (model) => { | ||||
| try { | try { | ||||
| @@ -106,6 +115,16 @@ export default function ModelPricing() { | |||||
| { title: t('维度'), dataIndex: 'dimensions', width: 90 }, | { title: t('维度'), dataIndex: 'dimensions', width: 90 }, | ||||
| { title: t('行数'), dataIndex: 'rows', width: 90 }, | { title: t('行数'), dataIndex: 'rows', width: 90 }, | ||||
| { title: t('计费单位'), dataIndex: 'billingUnit', width: 150 }, | { 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 价格'), | title: t('Legacy 价格'), | ||||
| dataIndex: 'legacyPrice', | dataIndex: 'legacyPrice', | ||||
| @@ -144,56 +163,49 @@ export default function ModelPricing() { | |||||
| return ( | return ( | ||||
| <Card style={{ marginTop: 10 }} data-testid='model-pricing-page'> | <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} | 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> | </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 | <Editor | ||||
| visible={editingModel !== null} | visible={editingModel !== null} | ||||
| modelName={editingModel || ''} | modelName={editingModel || ''} | ||||
| config={editingModel ? rules[editingModel] : null} | config={editingModel ? rules[editingModel] : null} | ||||
| hasExistingBillingConfig={Boolean(editingModel && rules[editingModel])} | |||||
| displayPricingItems={editingModel ? displayRules[editingModel] : []} | |||||
| onClose={() => setEditingModel(null)} | onClose={() => setEditingModel(null)} | ||||
| onSaved={refresh} | onSaved={refresh} | ||||
| /> | /> | ||||