删除用户渠道倍率(UserChannelRatio)功能,清理全链路相关代码: 后端删除 model/controller/router 层实现,前端删除 UI 组件和计费展示逻辑。 同时恢复 Token 分组选择和价格侧边栏分组过滤器的注释代码。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>master
| @@ -115,14 +115,14 @@ func GetUserPricing(c *gin.Context) { | |||||
| return | return | ||||
| } | } | ||||
| loggedIn, userId, userGroup, groupRatio := resolveCurrentUser(c) | |||||
| loggedIn, _, userGroup, groupRatio := resolveCurrentUser(c) | |||||
| if !loggedIn { | if !loggedIn { | ||||
| respondOriginalPrice(c, pricingData) | respondOriginalPrice(c, pricingData) | ||||
| return | return | ||||
| } | } | ||||
| bestUserChannelRatio := model.GetBestUserChannelRatio(userId, modelName) | |||||
| totalRatio := groupRatio * bestUserChannelRatio | |||||
| totalRatio := groupRatio | |||||
| savingsPercent := int(math.Round((1 - totalRatio) * 100)) | savingsPercent := int(math.Round((1 - totalRatio) * 100)) | ||||
| result := gin.H{ | result := gin.H{ | ||||
| @@ -131,7 +131,6 @@ func GetUserPricing(c *gin.Context) { | |||||
| "quota_type": pricingData.QuotaType, | "quota_type": pricingData.QuotaType, | ||||
| "group": userGroup, | "group": userGroup, | ||||
| "group_ratio": groupRatio, | "group_ratio": groupRatio, | ||||
| "user_channel_ratio": bestUserChannelRatio, | |||||
| "savings_percent": savingsPercent, | "savings_percent": savingsPercent, | ||||
| "logged_in": true, | "logged_in": true, | ||||
| } | } | ||||
| @@ -29,7 +29,7 @@ func setupPricingTestDB(t *testing.T) *gorm.DB { | |||||
| common.UsingSQLite = true | common.UsingSQLite = true | ||||
| common.RedisEnabled = false | common.RedisEnabled = false | ||||
| require.NoError(t, db.AutoMigrate(&model.UserChannelRatio{}, &model.User{})) | |||||
| require.NoError(t, db.AutoMigrate(&model.User{})) | |||||
| t.Cleanup(func() { | t.Cleanup(func() { | ||||
| model.DB = origDB | model.DB = origDB | ||||
| @@ -226,94 +226,7 @@ func TestGetUserPricing_LoggedIn_GroupDiscount(t *testing.T) { | |||||
| assert.Equal(t, "8折", resp["discount"]) | assert.Equal(t, "8折", resp["discount"]) | ||||
| } | } | ||||
| // TestGetUserPricing_LoggedIn_UserChannelRatio 已登录,有用户渠道倍率 | |||||
| func TestGetUserPricing_LoggedIn_UserChannelRatio(t *testing.T) { | |||||
| db := setupPricingTestDB(t) | |||||
| setPricingCache([]model.Pricing{ | |||||
| { | |||||
| ModelName: "gpt-4o", | |||||
| QuotaType: 0, | |||||
| ModelRatio: 15, | |||||
| CompletionRatio: 4, | |||||
| EnableGroup: []string{"default"}, | |||||
| }, | |||||
| }) | |||||
| // 创建用户(default 分组,倍率为1) | |||||
| user := &model.User{Id: 300, Group: "default", Username: "specialuser", Status: 1} | |||||
| require.NoError(t, db.Create(user).Error) | |||||
| // 插入用户渠道倍率 | |||||
| ucr := &model.UserChannelRatio{ | |||||
| UserId: 300, | |||||
| ModelName: "gpt-4o", | |||||
| ChannelId: 1, | |||||
| Ratio: 0.9, | |||||
| } | |||||
| require.NoError(t, ucr.Insert()) | |||||
| router := setupAuthRouter(300) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil) | |||||
| router.ServeHTTP(w, req) | |||||
| var resp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| assert.True(t, resp["success"].(bool)) | |||||
| assert.Equal(t, true, resp["logged_in"]) | |||||
| // group_ratio=1 * user_channel_ratio=0.9 = 0.9 | |||||
| assert.Equal(t, float64(0.9), resp["user_channel_ratio"]) | |||||
| assert.Equal(t, float64(10), resp["savings_percent"]) | |||||
| // 输入用户价: 30 * 0.9 = 27 | |||||
| assert.Equal(t, float64(27), resp["user_input"]) | |||||
| } | |||||
| // TestGetUserPricing_LoggedIn_BothDiscounts 分组倍率 + 用户渠道倍率叠加 | |||||
| func TestGetUserPricing_LoggedIn_BothDiscounts(t *testing.T) { | |||||
| db := setupPricingTestDB(t) | |||||
| setPricingCache([]model.Pricing{ | |||||
| { | |||||
| ModelName: "gpt-4o", | |||||
| QuotaType: 0, | |||||
| ModelRatio: 15, | |||||
| CompletionRatio: 4, | |||||
| EnableGroup: []string{"default", "vip"}, | |||||
| }, | |||||
| }) | |||||
| user := &model.User{Id: 400, Group: "vip", Username: "bothdiscount", Status: 1} | |||||
| require.NoError(t, db.Create(user).Error) | |||||
| // VIP 分组倍率 0.8 | |||||
| withGroupRatio(t, `{"default":1,"vip":0.8}`) | |||||
| // 用户渠道倍率 0.9 | |||||
| ucr := &model.UserChannelRatio{ | |||||
| UserId: 400, | |||||
| ModelName: "gpt-4o", | |||||
| ChannelId: 1, | |||||
| Ratio: 0.9, | |||||
| } | |||||
| require.NoError(t, ucr.Insert()) | |||||
| router := setupAuthRouter(400) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil) | |||||
| router.ServeHTTP(w, req) | |||||
| var resp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| assert.True(t, resp["success"].(bool)) | |||||
| assert.Equal(t, true, resp["logged_in"]) | |||||
| // total = 0.8 * 0.9 = 0.72, savings = 28% | |||||
| assert.Equal(t, float64(28), resp["savings_percent"]) | |||||
| // 输入: 30 * 0.72 = 21.6 | |||||
| assert.Equal(t, float64(21.6), resp["user_input"]) | |||||
| // 输出: 120 * 0.72 = 86.4 | |||||
| assert.Equal(t, float64(86.4), resp["user_output"]) | |||||
| } | |||||
| // TestGetUserPricing_PerCall_WithDiscount 按次计费 + 折扣 | // TestGetUserPricing_PerCall_WithDiscount 按次计费 + 折扣 | ||||
| func TestGetUserPricing_PerCall_WithDiscount(t *testing.T) { | func TestGetUserPricing_PerCall_WithDiscount(t *testing.T) { | ||||
| @@ -368,43 +281,3 @@ func TestFormatDiscount(t *testing.T) { | |||||
| } | } | ||||
| } | } | ||||
| // TestGetUserPricing_MultipleUserChannelRatios 多个渠道倍率取最低值 | |||||
| func TestGetUserPricing_MultipleUserChannelRatios(t *testing.T) { | |||||
| db := setupPricingTestDB(t) | |||||
| setPricingCache([]model.Pricing{ | |||||
| { | |||||
| ModelName: "gpt-4o", | |||||
| QuotaType: 0, | |||||
| ModelRatio: 15, | |||||
| CompletionRatio: 4, | |||||
| EnableGroup: []string{"default"}, | |||||
| }, | |||||
| }) | |||||
| user := &model.User{Id: 600, Group: "default", Username: "multichannel", Status: 1} | |||||
| require.NoError(t, db.Create(user).Error) | |||||
| // 多个渠道倍率,取最低值 0.7 | |||||
| for i, ratio := range []float64{0.9, 0.7, 0.8} { | |||||
| ucr := &model.UserChannelRatio{ | |||||
| UserId: 600, | |||||
| ModelName: "gpt-4o", | |||||
| ChannelId: i + 1, | |||||
| Ratio: ratio, | |||||
| } | |||||
| require.NoError(t, ucr.Insert()) | |||||
| } | |||||
| router := setupAuthRouter(600) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil) | |||||
| router.ServeHTTP(w, req) | |||||
| var resp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| assert.True(t, resp["success"].(bool)) | |||||
| // 应取最低倍率 0.7 | |||||
| assert.Equal(t, float64(0.7), resp["user_channel_ratio"]) | |||||
| assert.Equal(t, float64(30), resp["savings_percent"]) | |||||
| } | |||||
| @@ -204,8 +204,6 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { | |||||
| break | break | ||||
| } | } | ||||
| // 在渠道选择后更新价格数据以使用渠道定价 | |||||
| helper.UpdatePriceDataForChannelPricing(c, relayInfo, channel.Id) | |||||
| addUsedChannel(c, channel.Id) | addUsedChannel(c, channel.Id) | ||||
| bodyStorage, bodyErr := common.GetBodyStorage(c) | bodyStorage, bodyErr := common.GetBodyStorage(c) | ||||
| @@ -1,116 +0,0 @@ | |||||
| package controller | |||||
| import ( | |||||
| "net/http" | |||||
| "strconv" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/model" | |||||
| "github.com/gin-gonic/gin" | |||||
| ) | |||||
| func GetUserChannelRatios(c *gin.Context) { | |||||
| userIdStr := c.Param("user_id") | |||||
| userId, err := strconv.Atoi(userIdStr) | |||||
| if err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| ratios, err := model.GetUserChannelRatiosByUserId(userId) | |||||
| if err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| c.JSON(http.StatusOK, gin.H{ | |||||
| "success": true, | |||||
| "data": ratios, | |||||
| }) | |||||
| } | |||||
| type createUserChannelRatioRequest struct { | |||||
| UserId int `json:"user_id"` | |||||
| ModelName string `json:"model_name"` | |||||
| ChannelId int `json:"channel_id"` | |||||
| Ratio float64 `json:"ratio"` | |||||
| } | |||||
| func CreateUserChannelRatio(c *gin.Context) { | |||||
| var req createUserChannelRatioRequest | |||||
| if err := c.ShouldBindJSON(&req); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| if req.Ratio <= 0 { | |||||
| common.ApiErrorMsg(c, "ratio must be positive") | |||||
| return | |||||
| } | |||||
| ucr := &model.UserChannelRatio{ | |||||
| UserId: req.UserId, | |||||
| ModelName: req.ModelName, | |||||
| ChannelId: req.ChannelId, | |||||
| Ratio: req.Ratio, | |||||
| } | |||||
| if err := ucr.Insert(); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| c.JSON(http.StatusOK, gin.H{ | |||||
| "success": true, | |||||
| "data": ucr, | |||||
| }) | |||||
| } | |||||
| func UpdateUserChannelRatio(c *gin.Context) { | |||||
| idStr := c.Param("id") | |||||
| id, err := strconv.Atoi(idStr) | |||||
| if err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| var req struct { | |||||
| Ratio float64 `json:"ratio"` | |||||
| } | |||||
| if err := c.ShouldBindJSON(&req); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| if req.Ratio <= 0 { | |||||
| common.ApiErrorMsg(c, "ratio must be positive") | |||||
| return | |||||
| } | |||||
| ucr := &model.UserChannelRatio{ | |||||
| Id: id, | |||||
| Ratio: req.Ratio, | |||||
| } | |||||
| if err := ucr.Update(); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| c.JSON(http.StatusOK, gin.H{ | |||||
| "success": true, | |||||
| "data": ucr, | |||||
| }) | |||||
| } | |||||
| func DeleteUserChannelRatio(c *gin.Context) { | |||||
| idStr := c.Param("id") | |||||
| id, err := strconv.Atoi(idStr) | |||||
| if err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| if err := model.DeleteUserChannelRatioById(id); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| c.JSON(http.StatusOK, gin.H{ | |||||
| "success": true, | |||||
| }) | |||||
| } | |||||
| @@ -324,23 +324,43 @@ | |||||
| - 无冲突:推荐 `create_new` | - 无冲突:推荐 `create_new` | ||||
| - 仅 `aff_code` 冲突:推荐 `create_new` | - 仅 `aff_code` 冲突:推荐 `create_new` | ||||
| - 同邮箱且无其他矛盾:推荐 `merge_into_existing` | |||||
| - 同邮箱且无其他矛盾、且目标国内用户尚未在海外存在其他 `synced` 副本:推荐 `merge_into_existing` | |||||
| - 任意 OAuth 多对一、一对多关系:强制人工处理 | - 任意 OAuth 多对一、一对多关系:强制人工处理 | ||||
| --- | --- | ||||
| ## 执行一致性约束 | |||||
| - 预检查快照只用于冲突决策和界面展示,不作为最终额度结算依据。 | |||||
| - 执行阶段必须重新拉取海外用户最新快照,并以该快照中的最终额度为准。 | |||||
| - V1 版本要求在运维冻结窗口内执行批次。冻结窗口至少要覆盖待迁用户的充值、兑换码、额度转移和新请求入口,并等待已在途请求自然结束。 | |||||
| - 如果执行时最新快照与预检查结论发生冲突性漂移(例如邮箱归属变化、OAuth 绑定目标变化、原本计划合并的国内账号已存在其他海外 `synced` 副本),该条目必须失败并要求重新扫描,不得静默沿用旧决议。 | |||||
| --- | |||||
| ## 单用户迁移执行流程 | ## 单用户迁移执行流程 | ||||
| 执行顺序不能改变,必须先构建国内主账号,再将海外原账号降级。 | |||||
| 执行顺序不能改变,必须先构建国内主账号,再将海外原账号降级。执行阶段读取的“最新快照”必须发生在冻结窗口内。 | |||||
| ### `create_new` | ### `create_new` | ||||
| 1. 从海外拉取该用户最新快照 | |||||
| 1. 在冻结窗口内,从海外拉取该用户最新快照 | |||||
| 2. 在国内开启事务 | 2. 在国内开启事务 | ||||
| 3. 通过“按哈希导入”的专用逻辑创建国内主账号 | 3. 通过“按哈希导入”的专用逻辑创建国内主账号 | ||||
| - 不能走普通 `Insert()` | - 不能走普通 `Insert()` | ||||
| - 否则密码会再次哈希 | - 否则密码会再次哈希 | ||||
| 4. 复制主字段: | |||||
| 4. 在同一个国内事务中完成以下动作: | |||||
| - 创建国内主账号 | |||||
| - 复制主字段 | |||||
| - 复制内置 OAuth 字段 | |||||
| - 复制 `user_oauth_bindings` | |||||
| - 处理 `aff_code` 冲突 | |||||
| - 回写 `user_migration_items.target_user_id` | |||||
| 5. 如在执行阶段遇到新的唯一键冲突: | |||||
| - 直接将该迁移项标记为失败 | |||||
| - 要求重新扫描或人工修正 | |||||
| - 不允许按用户名/邮箱自动认领为“上次已创建成功”的账号 | |||||
| 6. 复制主字段: | |||||
| - `username` | - `username` | ||||
| - `password` | - `password` | ||||
| - `email` | - `email` | ||||
| @@ -350,31 +370,35 @@ | |||||
| - `group` | - `group` | ||||
| - `created_at` | - `created_at` | ||||
| - `setting` | - `setting` | ||||
| 5. 复制内置 OAuth ID 字段 | |||||
| 6. 复制 `user_oauth_bindings` | |||||
| 7. 复制内置 OAuth ID 字段 | |||||
| 8. 复制 `user_oauth_bindings` | |||||
| - 先通过 `slug` 将海外 `provider_id` 映射为国内 `provider_id` | - 先通过 `slug` 将海外 `provider_id` 映射为国内 `provider_id` | ||||
| 7. 处理邀请码 | |||||
| 9. 处理邀请码 | |||||
| - 如冲突则生成新的 `aff_code` | - 如冲突则生成新的 `aff_code` | ||||
| 8. 将国内额度设置为海外当前最终额度 | |||||
| 9. 国内事务提交,得到 `cn_user_id` | |||||
| 10. 调海外内部接口,将原海外用户转换为 `synced` | |||||
| - `source = synced` | |||||
| - `remote_user_id = cn_user_id` | |||||
| - `synced_quota = 当前额度` | |||||
| - `last_sync_at = now` | |||||
| 11. 更新迁移项为成功 | |||||
| 10. 将国内额度设置为“执行时最新快照中的最终额度” | |||||
| 11. 国内事务提交,得到 `cn_user_id` | |||||
| 12. 调海外内部接口,将原海外用户转换为 `synced` | |||||
| - `source = synced` | |||||
| - `remote_user_id = cn_user_id` | |||||
| - `synced_quota = 国内提交后的最终额度` | |||||
| - `last_sync_at = now` | |||||
| 13. 更新迁移项为成功 | |||||
| ### `merge_into_existing` | ### `merge_into_existing` | ||||
| 1. 锁定目标国内账号 | |||||
| 2. 重新校验该用户仍应合并到这个目标 | |||||
| 3. 补齐国内为空、海外有值的字段 | |||||
| 4. 补齐国内缺失的 OAuth 绑定 | |||||
| 5. 额度合并规则: | |||||
| - `国内最终额度 = 国内原额度 + 海外当前额度` | |||||
| 6. 提交国内事务 | |||||
| 7. 调海外接口将原账号转换为 `synced` | |||||
| 8. 更新迁移项为成功 | |||||
| 1. 在冻结窗口内拉取该用户最新快照 | |||||
| 2. 锁定目标国内账号 | |||||
| 3. 重新校验该用户仍应合并到这个目标 | |||||
| - 同邮箱或唯一 OAuth 目标仍然成立 | |||||
| - 目标国内账号在海外不存在其他 `synced` 副本 | |||||
| 4. 补齐国内为空、海外有值的字段 | |||||
| 5. 补齐国内缺失的 OAuth 绑定 | |||||
| 6. 额度合并规则: | |||||
| - `国内最终额度 = 国内原额度 + 执行时最新海外额度` | |||||
| - 通过 `migration_token` 保证每个迁移项最多叠加一次 | |||||
| 7. 提交国内事务 | |||||
| 8. 调海外接口将原账号转换为 `synced` | |||||
| 9. 更新迁移项为成功 | |||||
| ### 为什么不删除海外原用户 | ### 为什么不删除海外原用户 | ||||
| @@ -393,8 +417,11 @@ | |||||
| - `GET /api/internal/migration/users` | - `GET /api/internal/migration/users` | ||||
| - 分页返回候选用户快照 | - 分页返回候选用户快照 | ||||
| - 仅返回 `source=local` 的海外用户 | |||||
| - `GET /api/internal/migration/users/:id` | - `GET /api/internal/migration/users/:id` | ||||
| - 返回单用户最新快照 | - 返回单用户最新快照 | ||||
| - `GET /api/internal/migration/users/check-synced-copy?cn_user_id=:id` | |||||
| - 检查某个国内用户是否已在海外存在 `synced` 副本 | |||||
| - `POST /api/internal/migration/users/:id/convert-to-synced` | - `POST /api/internal/migration/users/:id/convert-to-synced` | ||||
| - 将海外原用户降级为 `synced` | - 将海外原用户降级为 `synced` | ||||
| @@ -406,6 +433,7 @@ | |||||
| - 用户存在 | - 用户存在 | ||||
| - 用户尚未迁移,或已迁移且目标一致 | - 用户尚未迁移,或已迁移且目标一致 | ||||
| - `remote_user_id` 不为空 | - `remote_user_id` 不为空 | ||||
| - 如果用户已是 `synced` 但 `remote_user_id` 与请求不一致,返回冲突错误 | |||||
| 幂等语义: | 幂等语义: | ||||
| @@ -486,6 +514,7 @@ | |||||
| - 触发预检查 | - 触发预检查 | ||||
| - `POST /api/user-migrations/items/:id/resolve` | - `POST /api/user-migrations/items/:id/resolve` | ||||
| - 设置处理策略 | - 设置处理策略 | ||||
| - 成功后重新统计批次状态;若所有项都已变为 `ready` 或 `skipped`,批次自动转为 `ready` | |||||
| - `POST /api/user-migrations/batches/:id/execute` | - `POST /api/user-migrations/batches/:id/execute` | ||||
| - 执行迁移 | - 执行迁移 | ||||
| - `POST /api/user-migrations/batches/:id/retry` | - `POST /api/user-migrations/batches/:id/retry` | ||||
| @@ -537,9 +566,9 @@ | |||||
| ## 冻结窗口设计 | ## 冻结窗口设计 | ||||
| 本次迁移不要求全站停机,但建议对“待迁批次中的海外用户”做短冻结窗口。 | |||||
| 本次迁移不要求全站停机,但 V1 版本将“冻结窗口”定义为执行前的强制运维前置条件,而不是可选建议。 | |||||
| 冻结范围建议仅限该批次用户: | |||||
| 冻结范围应仅限该批次用户: | |||||
| - 禁止充值 | - 禁止充值 | ||||
| - 禁止兑换码 | - 禁止兑换码 | ||||
| @@ -547,6 +576,11 @@ | |||||
| - 禁止发起新请求 | - 禁止发起新请求 | ||||
| - 等待已在途请求自然结束 | - 等待已在途请求自然结束 | ||||
| 执行要求: | |||||
| - root 操作员在点击“开始迁移”前确认冻结窗口已经生效 | |||||
| - 批次执行期间如果发现冻结条件被破坏,应立即停止后续条目并重新扫描 | |||||
| 目的: | 目的: | ||||
| - 稳定海外最终额度 | - 稳定海外最终额度 | ||||
| @@ -207,8 +207,7 @@ func InitDB() (err error) { | |||||
| return err | return err | ||||
| } | } | ||||
| LoadEmailQuotaCache() | LoadEmailQuotaCache() | ||||
| LoadUserChannelRatioCache() | |||||
| return nil | |||||
| return nil | |||||
| } else { | } else { | ||||
| common.FatalLog(err) | common.FatalLog(err) | ||||
| } | } | ||||
| @@ -283,7 +282,6 @@ func migrateDB() error { | |||||
| &SubscriptionPreConsumeRecord{}, | &SubscriptionPreConsumeRecord{}, | ||||
| &CustomOAuthProvider{}, | &CustomOAuthProvider{}, | ||||
| &UserOAuthBinding{}, | &UserOAuthBinding{}, | ||||
| &UserChannelRatio{}, | |||||
| &PendingSyncRecord{}, | &PendingSyncRecord{}, | ||||
| &QuotaSyncLog{}, | &QuotaSyncLog{}, | ||||
| &EmailQuotaRule{}, | &EmailQuotaRule{}, | ||||
| @@ -349,7 +347,6 @@ func migrateDBFast() error { | |||||
| {&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"}, | {&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"}, | ||||
| {&CustomOAuthProvider{}, "CustomOAuthProvider"}, | {&CustomOAuthProvider{}, "CustomOAuthProvider"}, | ||||
| {&UserOAuthBinding{}, "UserOAuthBinding"}, | {&UserOAuthBinding{}, "UserOAuthBinding"}, | ||||
| {&UserChannelRatio{}, "UserChannelRatio"}, | |||||
| {&PendingSyncRecord{}, "PendingSyncRecord"}, | {&PendingSyncRecord{}, "PendingSyncRecord"}, | ||||
| {&QuotaSyncLog{}, "QuotaSyncLog"}, | {&QuotaSyncLog{}, "QuotaSyncLog"}, | ||||
| {&EmailQuotaRule{}, "EmailQuotaRule"}, | {&EmailQuotaRule{}, "EmailQuotaRule"}, | ||||
| @@ -1,129 +0,0 @@ | |||||
| package model | |||||
| import ( | |||||
| "strconv" | |||||
| "strings" | |||||
| "sync" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| ) | |||||
| var ( | |||||
| userChannelRatioCache = make(map[string]float64) // key: "userId:modelName:channelId" -> ratio | |||||
| userChannelRatioCacheLock sync.RWMutex | |||||
| ) | |||||
| // UserChannelRatio 用户-模型-渠道倍率表 | |||||
| type UserChannelRatio struct { | |||||
| Id int `json:"id" gorm:"primaryKey"` | |||||
| UserId int `json:"user_id" gorm:"not null;uniqueIndex:idx_user_model_channel,priority:1"` | |||||
| ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:idx_user_model_channel,priority:2"` | |||||
| ChannelId int `json:"channel_id" gorm:"not null;uniqueIndex:idx_user_model_channel,priority:3"` | |||||
| Ratio float64 `json:"ratio" gorm:"default:1"` | |||||
| CreatedAt int64 `json:"created_at" gorm:"bigint"` | |||||
| UpdatedAt int64 `json:"updated_at" gorm:"bigint"` | |||||
| } | |||||
| func getUserChannelRatioCacheKey(userId int, modelName string, channelId int) string { | |||||
| return strconv.Itoa(userId) + ":" + modelName + ":" + strconv.Itoa(channelId) | |||||
| } | |||||
| func setUserChannelRatioCache(key string, ratio float64) { | |||||
| userChannelRatioCacheLock.Lock() | |||||
| userChannelRatioCache[key] = ratio | |||||
| userChannelRatioCacheLock.Unlock() | |||||
| } | |||||
| func removeUserChannelRatioCache(key string) { | |||||
| userChannelRatioCacheLock.Lock() | |||||
| delete(userChannelRatioCache, key) | |||||
| userChannelRatioCacheLock.Unlock() | |||||
| } | |||||
| // GetUserChannelRatio 获取用户在指定模型+渠道的倍率(纯内存读) | |||||
| // 未命中返回 1.0(不影响计费) | |||||
| func GetUserChannelRatio(userId int, modelName string, channelId int) float64 { | |||||
| key := getUserChannelRatioCacheKey(userId, modelName, channelId) | |||||
| userChannelRatioCacheLock.RLock() | |||||
| ratio, ok := userChannelRatioCache[key] | |||||
| userChannelRatioCacheLock.RUnlock() | |||||
| if !ok { | |||||
| return 1.0 | |||||
| } | |||||
| return ratio | |||||
| } | |||||
| // LoadUserChannelRatioCache 全量加载到内存(启动时调用) | |||||
| func LoadUserChannelRatioCache() { | |||||
| var records []*UserChannelRatio | |||||
| if err := DB.Find(&records).Error; err != nil { | |||||
| common.SysError("[UserChannelRatio] LoadCache failed: " + err.Error()) | |||||
| return | |||||
| } | |||||
| userChannelRatioCacheLock.Lock() | |||||
| userChannelRatioCache = make(map[string]float64, len(records)) | |||||
| for _, r := range records { | |||||
| key := getUserChannelRatioCacheKey(r.UserId, r.ModelName, r.ChannelId) | |||||
| userChannelRatioCache[key] = r.Ratio | |||||
| } | |||||
| userChannelRatioCacheLock.Unlock() | |||||
| common.SysLog("[UserChannelRatio] cache loaded " + strconv.Itoa(len(records)) + " records") | |||||
| } | |||||
| func (ucr *UserChannelRatio) Insert() error { | |||||
| ucr.CreatedAt = common.GetTimestamp() | |||||
| ucr.UpdatedAt = common.GetTimestamp() | |||||
| err := DB.Create(ucr).Error | |||||
| if err == nil { | |||||
| setUserChannelRatioCache(getUserChannelRatioCacheKey(ucr.UserId, ucr.ModelName, ucr.ChannelId), ucr.Ratio) | |||||
| } | |||||
| return err | |||||
| } | |||||
| func (ucr *UserChannelRatio) Update() error { | |||||
| var existing UserChannelRatio | |||||
| if err := DB.First(&existing, ucr.Id).Error; err != nil { | |||||
| return err | |||||
| } | |||||
| ucr.UpdatedAt = common.GetTimestamp() | |||||
| err := DB.Model(&UserChannelRatio{}).Where("id = ?", ucr.Id). | |||||
| Select("ratio", "updated_at"). | |||||
| Updates(ucr).Error | |||||
| if err == nil { | |||||
| setUserChannelRatioCache(getUserChannelRatioCacheKey(existing.UserId, existing.ModelName, existing.ChannelId), ucr.Ratio) | |||||
| } | |||||
| return err | |||||
| } | |||||
| func DeleteUserChannelRatioById(id int) error { | |||||
| var existing UserChannelRatio | |||||
| if err := DB.First(&existing, id).Error; err != nil { | |||||
| return err | |||||
| } | |||||
| err := DB.Delete(&existing).Error | |||||
| if err == nil { | |||||
| removeUserChannelRatioCache(getUserChannelRatioCacheKey(existing.UserId, existing.ModelName, existing.ChannelId)) | |||||
| } | |||||
| return err | |||||
| } | |||||
| // GetUserChannelRatiosByUserId 获取指定用户的所有倍率记录 | |||||
| func GetUserChannelRatiosByUserId(userId int) ([]*UserChannelRatio, error) { | |||||
| var list []*UserChannelRatio | |||||
| err := DB.Where("user_id = ?", userId).Find(&list).Error | |||||
| return list, err | |||||
| } | |||||
| // GetBestUserChannelRatio 从内存缓存中查找指定用户+模型的最低渠道倍率(未命中返回 1.0) | |||||
| func GetBestUserChannelRatio(userId int, modelName string) float64 { | |||||
| best := 1.0 | |||||
| prefix := strconv.Itoa(userId) + ":" + modelName + ":" | |||||
| userChannelRatioCacheLock.RLock() | |||||
| for key, ratio := range userChannelRatioCache { | |||||
| if len(key) > len(prefix) && strings.HasPrefix(key, prefix) && ratio < best { | |||||
| best = ratio | |||||
| } | |||||
| } | |||||
| userChannelRatioCacheLock.RUnlock() | |||||
| return best | |||||
| } | |||||
| @@ -1,105 +0,0 @@ | |||||
| package model | |||||
| import ( | |||||
| "testing" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/glebarez/sqlite" | |||||
| "github.com/stretchr/testify/assert" | |||||
| "github.com/stretchr/testify/require" | |||||
| "gorm.io/gorm" | |||||
| ) | |||||
| func setupUserChannelRatioDB(t *testing.T) *gorm.DB { | |||||
| t.Helper() | |||||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||||
| require.NoError(t, err) | |||||
| sqlDB, _ := db.DB() | |||||
| sqlDB.SetMaxOpenConns(1) | |||||
| origDB := DB | |||||
| DB = db | |||||
| common.UsingSQLite = true | |||||
| common.RedisEnabled = false | |||||
| require.NoError(t, db.AutoMigrate(&UserChannelRatio{})) | |||||
| t.Cleanup(func() { | |||||
| DB = origDB | |||||
| sqlDB.Close() | |||||
| }) | |||||
| return db | |||||
| } | |||||
| func TestUserChannelRatioCRUD(t *testing.T) { | |||||
| setupUserChannelRatioDB(t) | |||||
| LoadUserChannelRatioCache() | |||||
| ucr := &UserChannelRatio{ | |||||
| UserId: 1, | |||||
| ModelName: "gpt-4o", | |||||
| ChannelId: 5, | |||||
| Ratio: 0.8, | |||||
| } | |||||
| err := ucr.Insert() | |||||
| require.NoError(t, err) | |||||
| assert.True(t, ucr.Id > 0) | |||||
| ratio := GetUserChannelRatio(1, "gpt-4o", 5) | |||||
| assert.Equal(t, 0.8, ratio) | |||||
| ratio = GetUserChannelRatio(999, "nonexistent", 999) | |||||
| assert.Equal(t, 1.0, ratio) | |||||
| ucr.Ratio = 1.2 | |||||
| err = ucr.Update() | |||||
| require.NoError(t, err) | |||||
| ratio = GetUserChannelRatio(1, "gpt-4o", 5) | |||||
| assert.Equal(t, 1.2, ratio) | |||||
| list, err := GetUserChannelRatiosByUserId(1) | |||||
| require.NoError(t, err) | |||||
| assert.Len(t, list, 1) | |||||
| assert.Equal(t, "gpt-4o", list[0].ModelName) | |||||
| err = DeleteUserChannelRatioById(ucr.Id) | |||||
| require.NoError(t, err) | |||||
| ratio = GetUserChannelRatio(1, "gpt-4o", 5) | |||||
| assert.Equal(t, 1.0, ratio) | |||||
| list, err = GetUserChannelRatiosByUserId(1) | |||||
| require.NoError(t, err) | |||||
| assert.Len(t, list, 0) | |||||
| } | |||||
| func TestUserChannelRatioUniqueConstraint(t *testing.T) { | |||||
| setupUserChannelRatioDB(t) | |||||
| LoadUserChannelRatioCache() | |||||
| ucr1 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 0.8} | |||||
| err := ucr1.Insert() | |||||
| require.NoError(t, err) | |||||
| ucr2 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 1.0} | |||||
| err = ucr2.Insert() | |||||
| assert.Error(t, err) | |||||
| ucr3 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 6, Ratio: 1.5} | |||||
| err = ucr3.Insert() | |||||
| assert.NoError(t, err) | |||||
| } | |||||
| func TestLoadUserChannelRatioCache(t *testing.T) { | |||||
| setupUserChannelRatioDB(t) | |||||
| ucr1 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 0.8} | |||||
| ucr2 := &UserChannelRatio{UserId: 2, ModelName: "claude-3", ChannelId: 10, Ratio: 1.5} | |||||
| require.NoError(t, ucr1.Insert()) | |||||
| require.NoError(t, ucr2.Insert()) | |||||
| LoadUserChannelRatioCache() | |||||
| assert.Equal(t, 0.8, GetUserChannelRatio(1, "gpt-4o", 5)) | |||||
| assert.Equal(t, 1.5, GetUserChannelRatio(2, "claude-3", 10)) | |||||
| assert.Equal(t, 1.0, GetUserChannelRatio(3, "nonexistent", 99)) | |||||
| } | |||||
| @@ -287,7 +287,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage | |||||
| dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio) | dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio) | ||||
| dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | ||||
| ratio := dModelRatio.Mul(dGroupRatio).Mul(decimal.NewFromFloat(relayInfo.PriceData.UserChannelRatio)) | |||||
| ratio := dModelRatio.Mul(dGroupRatio) | |||||
| // openai web search 工具计费 | // openai web search 工具计费 | ||||
| var dWebSearchQuota decimal.Decimal | var dWebSearchQuota decimal.Decimal | ||||
| @@ -401,7 +401,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage | |||||
| quotaCalculateDecimal = decimal.NewFromInt(1) | quotaCalculateDecimal = decimal.NewFromInt(1) | ||||
| } | } | ||||
| } else { | } else { | ||||
| quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio).Mul(decimal.NewFromFloat(relayInfo.PriceData.UserChannelRatio)) | |||||
| quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) | |||||
| } | } | ||||
| // 添加 responses tools call 调用的配额 | // 添加 responses tools call 调用的配额 | ||||
| quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) | quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) | ||||
| @@ -4,9 +4,7 @@ import ( | |||||
| "fmt" | "fmt" | ||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/constant" | |||||
| "github.com/QuantumNous/new-api/logger" | "github.com/QuantumNous/new-api/logger" | ||||
| "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/setting/operation_setting" | "github.com/QuantumNous/new-api/setting/operation_setting" | ||||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | "github.com/QuantumNous/new-api/setting/ratio_setting" | ||||
| @@ -116,7 +114,6 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens | |||||
| CacheCreationRatio: cacheCreationRatio, | CacheCreationRatio: cacheCreationRatio, | ||||
| CacheCreation5mRatio: cacheCreationRatio, | CacheCreation5mRatio: cacheCreationRatio, | ||||
| CacheCreation1hRatio: cacheCreationRatio * types.ClaudeCacheCreation1hMultiplier, | CacheCreation1hRatio: cacheCreationRatio * types.ClaudeCacheCreation1hMultiplier, | ||||
| UserChannelRatio: 1.0, | |||||
| } | } | ||||
| if common.DebugEnabled { | if common.DebugEnabled { | ||||
| @@ -154,7 +151,6 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types. | |||||
| ModelPrice: modelPrice, | ModelPrice: modelPrice, | ||||
| Quota: quota, | Quota: quota, | ||||
| GroupRatioInfo: groupRatioInfo, | GroupRatioInfo: groupRatioInfo, | ||||
| UserChannelRatio: 1.0, | |||||
| } | } | ||||
| return priceData | return priceData | ||||
| } | } | ||||
| @@ -170,30 +166,3 @@ func ContainPriceOrRatio(modelName string) bool { | |||||
| } | } | ||||
| return false | return false | ||||
| } | } | ||||
| // UpdatePriceDataForChannelPricing 保留原调用点,仅在选定渠道后应用用户渠道倍率。 | |||||
| func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInfo, channelId int) { | |||||
| if info == nil || channelId <= 0 { | |||||
| return | |||||
| } | |||||
| ApplyUserChannelRatio(c, info, channelId) | |||||
| } | |||||
| // ApplyUserChannelRatio 在渠道选定后应用用户倍率 | |||||
| func ApplyUserChannelRatio(c *gin.Context, info *relaycommon.RelayInfo, channelId int) { | |||||
| if info == nil || channelId <= 0 { | |||||
| return | |||||
| } | |||||
| userId := common.GetContextKeyInt(c, constant.ContextKeyUserId) | |||||
| if userId <= 0 { | |||||
| return | |||||
| } | |||||
| userRatio := model.GetUserChannelRatio(userId, info.OriginModelName, channelId) | |||||
| if userRatio == 1.0 { | |||||
| return | |||||
| } | |||||
| info.PriceData.UserChannelRatio = userRatio | |||||
| if info.PriceData.QuotaToPreConsume > 0 { | |||||
| info.PriceData.QuotaToPreConsume = int(float64(info.PriceData.QuotaToPreConsume) * userRatio) | |||||
| } | |||||
| } | |||||
| @@ -6,7 +6,6 @@ import ( | |||||
| "testing" | "testing" | ||||
| "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/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" | ||||
| @@ -39,8 +38,6 @@ func setupUsePriceSwitchTest(t *testing.T) { | |||||
| common.UsingSQLite = true | common.UsingSQLite = true | ||||
| common.RedisEnabled = false | common.RedisEnabled = false | ||||
| require.NoError(t, db.AutoMigrate(&model.UserChannelRatio{})) | |||||
| require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"`+testModelUsePriceSwitch+`":0.5}`)) | require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"`+testModelUsePriceSwitch+`":0.5}`)) | ||||
| require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testModelUsePriceSwitch+`":15}`)) | require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testModelUsePriceSwitch+`":15}`)) | ||||
| require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3}`)) | require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3}`)) | ||||
| @@ -119,38 +116,6 @@ func TestGlobalRatiosFilledWhenUseRatioMode(t *testing.T) { | |||||
| assert.Equal(t, 2.0, priceData.ImageRatio) | assert.Equal(t, 2.0, priceData.ImageRatio) | ||||
| } | } | ||||
| func TestUpdatePriceDataForChannelPricingOnlyAppliesUserChannelRatio(t *testing.T) { | |||||
| setupUsePriceSwitchTest(t) | |||||
| ratio := &model.UserChannelRatio{ | |||||
| UserId: 9, | |||||
| ModelName: testModelUsePriceSwitch, | |||||
| ChannelId: 9901, | |||||
| Ratio: 0.8, | |||||
| } | |||||
| require.NoError(t, ratio.Insert()) | |||||
| c := buildTestContext(t) | |||||
| c.Set(string(constant.ContextKeyUserId), 9) | |||||
| info := &relaycommon.RelayInfo{ | |||||
| OriginModelName: testModelUsePriceSwitch, | |||||
| PriceData: types.PriceData{ | |||||
| ModelPrice: 0.5, | |||||
| UsePrice: true, | |||||
| QuotaToPreConsume: 1000, | |||||
| UserChannelRatio: 1.0, | |||||
| }, | |||||
| } | |||||
| UpdatePriceDataForChannelPricing(c, info, 9901) | |||||
| assert.Equal(t, 0.8, info.PriceData.UserChannelRatio) | |||||
| assert.Equal(t, 800, info.PriceData.QuotaToPreConsume) | |||||
| assert.Equal(t, 0.5, info.PriceData.ModelPrice) | |||||
| } | |||||
| // --- HandleGroupRatio tests --- | |||||
| func TestHandleGroupRatio_DefaultGroup(t *testing.T) { | func TestHandleGroupRatio_DefaultGroup(t *testing.T) { | ||||
| setupUsePriceSwitchTest(t) | setupUsePriceSwitchTest(t) | ||||
| @@ -191,16 +191,6 @@ func SetApiRouter(router *gin.Engine) { | |||||
| optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 | optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 | ||||
| } | } | ||||
| // 用户倍率路由(管理员权限) | |||||
| userChannelRatioRoute := apiRouter.Group("/user_channel_ratio") | |||||
| userChannelRatioRoute.Use(middleware.AdminAuth()) | |||||
| { | |||||
| userChannelRatioRoute.GET("/:user_id", controller.GetUserChannelRatios) | |||||
| userChannelRatioRoute.POST("/", controller.CreateUserChannelRatio) | |||||
| userChannelRatioRoute.PUT("/:id", controller.UpdateUserChannelRatio) | |||||
| userChannelRatioRoute.DELETE("/:id", controller.DeleteUserChannelRatio) | |||||
| } | |||||
| // 邮箱后缀额度规则路由(管理员权限) | // 邮箱后缀额度规则路由(管理员权限) | ||||
| emailQuotaRuleRoute := apiRouter.Group("/email_quota_rule") | emailQuotaRuleRoute := apiRouter.Group("/email_quota_rule") | ||||
| emailQuotaRuleRoute.Use(middleware.AdminAuth()) | emailQuotaRuleRoute.Use(middleware.AdminAuth()) | ||||
| @@ -41,9 +41,6 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m | |||||
| other["cache_ratio"] = cacheRatio | other["cache_ratio"] = cacheRatio | ||||
| other["model_price"] = modelPrice | other["model_price"] = modelPrice | ||||
| other["user_group_ratio"] = userGroupRatio | other["user_group_ratio"] = userGroupRatio | ||||
| if relayInfo != nil && relayInfo.PriceData.UserChannelRatio != 1.0 { | |||||
| other["user_channel_ratio"] = relayInfo.PriceData.UserChannelRatio | |||||
| } | |||||
| other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli()) | other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli()) | ||||
| if relayInfo.ReasoningEffort != "" { | if relayInfo.ReasoningEffort != "" { | ||||
| other["reasoning_effort"] = relayInfo.ReasoningEffort | other["reasoning_effort"] = relayInfo.ReasoningEffort | ||||
| @@ -37,7 +37,6 @@ type QuotaInfo struct { | |||||
| ModelPrice float64 | ModelPrice float64 | ||||
| ModelRatio float64 | ModelRatio float64 | ||||
| GroupRatio float64 | GroupRatio float64 | ||||
| UserChannelRatio float64 | |||||
| } | } | ||||
| func hasCustomModelRatio(modelName string, currentRatio float64) bool { | func hasCustomModelRatio(modelName string, currentRatio float64) bool { | ||||
| @@ -54,7 +53,7 @@ func calculateAudioQuota(info QuotaInfo) int { | |||||
| quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | ||||
| groupRatio := decimal.NewFromFloat(info.GroupRatio) | groupRatio := decimal.NewFromFloat(info.GroupRatio) | ||||
| quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio).Mul(decimal.NewFromFloat(info.UserChannelRatio)) | |||||
| quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio) | |||||
| return int(quota.IntPart()) | return int(quota.IntPart()) | ||||
| } | } | ||||
| @@ -64,7 +63,7 @@ func calculateAudioQuota(info QuotaInfo) int { | |||||
| groupRatio := decimal.NewFromFloat(info.GroupRatio) | groupRatio := decimal.NewFromFloat(info.GroupRatio) | ||||
| modelRatio := decimal.NewFromFloat(info.ModelRatio) | modelRatio := decimal.NewFromFloat(info.ModelRatio) | ||||
| ratio := groupRatio.Mul(modelRatio).Mul(decimal.NewFromFloat(info.UserChannelRatio)) | |||||
| ratio := groupRatio.Mul(modelRatio) | |||||
| inputTextTokens := decimal.NewFromInt(int64(info.InputDetails.TextTokens)) | inputTextTokens := decimal.NewFromInt(int64(info.InputDetails.TextTokens)) | ||||
| outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens)) | outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens)) | ||||
| @@ -135,7 +134,6 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag | |||||
| UsePrice: relayInfo.UsePrice, | UsePrice: relayInfo.UsePrice, | ||||
| ModelRatio: modelRatio, | ModelRatio: modelRatio, | ||||
| GroupRatio: actualGroupRatio, | GroupRatio: actualGroupRatio, | ||||
| UserChannelRatio: relayInfo.PriceData.UserChannelRatio, | |||||
| } | } | ||||
| quota := calculateAudioQuota(quotaInfo) | quota := calculateAudioQuota(quotaInfo) | ||||
| @@ -189,7 +187,6 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod | |||||
| UsePrice: usePrice, | UsePrice: usePrice, | ||||
| ModelRatio: modelRatio, | ModelRatio: modelRatio, | ||||
| GroupRatio: groupRatio, | GroupRatio: groupRatio, | ||||
| UserChannelRatio: relayInfo.PriceData.UserChannelRatio, | |||||
| } | } | ||||
| quota := calculateAudioQuota(quotaInfo) | quota := calculateAudioQuota(quotaInfo) | ||||
| @@ -252,7 +249,6 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, | |||||
| completionRatio := relayInfo.PriceData.CompletionRatio | completionRatio := relayInfo.PriceData.CompletionRatio | ||||
| modelRatio := relayInfo.PriceData.ModelRatio | modelRatio := relayInfo.PriceData.ModelRatio | ||||
| groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio | groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio | ||||
| userChannelRatio := relayInfo.PriceData.UserChannelRatio | |||||
| modelPrice := relayInfo.PriceData.ModelPrice | modelPrice := relayInfo.PriceData.ModelPrice | ||||
| cacheRatio := relayInfo.PriceData.CacheRatio | cacheRatio := relayInfo.PriceData.CacheRatio | ||||
| cacheTokens := usage.PromptTokensDetails.CachedTokens | cacheTokens := usage.PromptTokensDetails.CachedTokens | ||||
| @@ -287,9 +283,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, | |||||
| calculateQuota += float64(remainingCacheCreationTokens) * cacheCreationRatio | calculateQuota += float64(remainingCacheCreationTokens) * cacheCreationRatio | ||||
| } | } | ||||
| calculateQuota += float64(completionTokens) * completionRatio | calculateQuota += float64(completionTokens) * completionRatio | ||||
| calculateQuota = calculateQuota * groupRatio * modelRatio * userChannelRatio | |||||
| calculateQuota = calculateQuota * groupRatio * modelRatio | |||||
| } else { | } else { | ||||
| calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio * userChannelRatio | |||||
| calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio | |||||
| } | } | ||||
| if modelRatio != 0 && calculateQuota <= 0 { | if modelRatio != 0 && calculateQuota <= 0 { | ||||
| @@ -394,7 +390,6 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u | |||||
| UsePrice: usePrice, | UsePrice: usePrice, | ||||
| ModelRatio: modelRatio, | ModelRatio: modelRatio, | ||||
| GroupRatio: groupRatio, | GroupRatio: groupRatio, | ||||
| UserChannelRatio: relayInfo.PriceData.UserChannelRatio, | |||||
| } | } | ||||
| quota := calculateAudioQuota(quotaInfo) | quota := calculateAudioQuota(quotaInfo) | ||||
| @@ -29,7 +29,6 @@ type PriceData struct { | |||||
| Quota int // 按次计费的最终额度(MJ / Task) | Quota int // 按次计费的最终额度(MJ / Task) | ||||
| QuotaToPreConsume int // 按量计费的预消耗额度 | QuotaToPreConsume int // 按量计费的预消耗额度 | ||||
| GroupRatioInfo GroupRatioInfo | GroupRatioInfo GroupRatioInfo | ||||
| UserChannelRatio float64 // 用户-模型-渠道倍率(默认 1.0) | |||||
| } | } | ||||
| // ApplyChannelPricingRatios 将渠道定价的扩展比率应用到 PriceData(非零值覆盖) | // ApplyChannelPricingRatios 将渠道定价的扩展比率应用到 PriceData(非零值覆盖) | ||||
| @@ -65,5 +64,5 @@ func (p *PriceData) AddOtherRatio(key string, ratio float64) { | |||||
| } | } | ||||
| func (p *PriceData) ToSetting() string { | func (p *PriceData) ToSetting() string { | ||||
| return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f, UserChannelRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio, p.UserChannelRatio) | |||||
| return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio) | |||||
| } | } | ||||
| @@ -122,7 +122,7 @@ const PricingSidebar = ({ | |||||
| t={t} | t={t} | ||||
| /> */} | /> */} | ||||
| {/* <PricingGroups | |||||
| <PricingGroups | |||||
| filterGroup={filterGroup} | filterGroup={filterGroup} | ||||
| setFilterGroup={handleGroupClick} | setFilterGroup={handleGroupClick} | ||||
| usableGroup={categoryProps.usableGroup} | usableGroup={categoryProps.usableGroup} | ||||
| @@ -130,7 +130,7 @@ const PricingSidebar = ({ | |||||
| models={groupCountModels} | models={groupCountModels} | ||||
| loading={loading} | loading={loading} | ||||
| t={t} | t={t} | ||||
| /> */} | |||||
| /> | |||||
| <PricingQuotaTypes | <PricingQuotaTypes | ||||
| filterQuotaType={filterQuotaType} | filterQuotaType={filterQuotaType} | ||||
| @@ -143,9 +143,9 @@ const EditTokenModal = (props) => { | |||||
| } | } | ||||
| } | } | ||||
| setGroups(localGroupOptions); | setGroups(localGroupOptions); | ||||
| // if (statusState?.status?.default_use_auto_group && formApiRef.current) { | |||||
| // formApiRef.current.setValue('group', 'auto'); | |||||
| // } | |||||
| if (statusState?.status?.default_use_auto_group && formApiRef.current) { | |||||
| formApiRef.current.setValue('group', 'auto'); | |||||
| } | |||||
| } else { | } else { | ||||
| showError(t(message)); | showError(t(message)); | ||||
| } | } | ||||
| @@ -360,7 +360,7 @@ const EditTokenModal = (props) => { | |||||
| showClear | showClear | ||||
| /> | /> | ||||
| </Col> | </Col> | ||||
| {/* <Col span={24}> | |||||
| <Col span={24}> | |||||
| {groups.length > 0 ? ( | {groups.length > 0 ? ( | ||||
| <Form.Select | <Form.Select | ||||
| field='group' | field='group' | ||||
| @@ -394,7 +394,7 @@ const EditTokenModal = (props) => { | |||||
| '开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道', | '开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道', | ||||
| )} | )} | ||||
| /> | /> | ||||
| </Col> */} | |||||
| </Col> | |||||
| <Col xs={24} sm={24} md={24} lg={10} xl={10}> | <Col xs={24} sm={24} md={24} lg={10} xl={10}> | ||||
| <Form.DatePicker | <Form.DatePicker | ||||
| field='expired_time' | field='expired_time' | ||||
| @@ -56,7 +56,6 @@ import { | |||||
| IconPlus, | IconPlus, | ||||
| } from '@douyinfe/semi-icons'; | } from '@douyinfe/semi-icons'; | ||||
| import UserBindingManagementModal from './UserBindingManagementModal'; | import UserBindingManagementModal from './UserBindingManagementModal'; | ||||
| import UserRatioSection from './UserRatioSection'; | |||||
| import UserModelRateLimitSection from './UserModelRateLimitSection'; | import UserModelRateLimitSection from './UserModelRateLimitSection'; | ||||
| const { Text, Title } = Typography; | const { Text, Title } = Typography; | ||||
| @@ -345,12 +344,6 @@ const EditUserModal = (props) => { | |||||
| )} | )} | ||||
| {/* 倍率设置 */} | |||||
| {userId && ( | |||||
| <Card className='!rounded-2xl shadow-sm border-0'> | |||||
| <UserRatioSection userId={userId} /> | |||||
| </Card> | |||||
| )} | |||||
| {/* 模型速率限制 */} | {/* 模型速率限制 */} | ||||
| {userId && ( | {userId && ( | ||||
| <Card className='!rounded-2xl shadow-sm border-0'> | <Card className='!rounded-2xl shadow-sm border-0'> | ||||
| @@ -1,241 +0,0 @@ | |||||
| import React, { useEffect, useState, useRef } from 'react'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| import { API, showError, showSuccess } from '../../../../helpers'; | |||||
| import { | |||||
| Button, | |||||
| Table, | |||||
| InputNumber, | |||||
| Modal, | |||||
| Form, | |||||
| Avatar, | |||||
| Typography, | |||||
| Popconfirm, | |||||
| } from '@douyinfe/semi-ui'; | |||||
| import { IconStar } from '@douyinfe/semi-icons'; | |||||
| const { Text } = Typography; | |||||
| const UserRatioSection = ({ userId }) => { | |||||
| const { t } = useTranslation(); | |||||
| const [ratios, setRatios] = useState([]); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [addModalVisible, setAddModalVisible] = useState(false); | |||||
| const [channelOptions, setChannelOptions] = useState([]); | |||||
| const [modelOptions, setModelOptions] = useState([]); | |||||
| const formApiRef = useRef(null); | |||||
| const loadRatios = async () => { | |||||
| if (!userId) return; | |||||
| setLoading(true); | |||||
| try { | |||||
| const res = await API.get(`/api/user_channel_ratio/${userId}`); | |||||
| const { success, data, message } = res.data; | |||||
| if (success) { | |||||
| setRatios(data || []); | |||||
| } else { | |||||
| showError(message); | |||||
| } | |||||
| } catch (e) { | |||||
| showError(e.message); | |||||
| } | |||||
| setLoading(false); | |||||
| }; | |||||
| const loadChannels = async () => { | |||||
| try { | |||||
| const res = await API.get('/api/channel/?p=0&page_size=500'); | |||||
| const { success, data, message } = res.data; | |||||
| if (success) { | |||||
| const options = (data?.items || []).map((ch) => ({ | |||||
| label: ch.remark | |||||
| ? `${ch.name} (${ch.remark})` | |||||
| : `${ch.name} (ID: ${ch.id})`, | |||||
| value: ch.id, | |||||
| models: (ch.models || '').split(',').map((m) => m.trim()).filter(Boolean), | |||||
| })); | |||||
| setChannelOptions(options); | |||||
| } else { | |||||
| showError(message); | |||||
| } | |||||
| } catch (e) { | |||||
| showError(e.message); | |||||
| } | |||||
| }; | |||||
| useEffect(() => { | |||||
| loadRatios(); | |||||
| loadChannels(); | |||||
| }, [userId]); | |||||
| const handleAdd = async (values) => { | |||||
| const res = await API.post('/api/user_channel_ratio/', { | |||||
| user_id: userId, | |||||
| model_name: values.model_name, | |||||
| channel_id: values.channel_id, | |||||
| ratio: values.ratio, | |||||
| }); | |||||
| const { success, message } = res.data; | |||||
| if (success) { | |||||
| showSuccess(t('添加成功')); | |||||
| setAddModalVisible(false); | |||||
| loadRatios(); | |||||
| } else { | |||||
| showError(message); | |||||
| } | |||||
| }; | |||||
| const handleUpdate = async (id, newRatio) => { | |||||
| const res = await API.put(`/api/user_channel_ratio/${id}`, { | |||||
| ratio: newRatio, | |||||
| }); | |||||
| const { success, message } = res.data; | |||||
| if (success) { | |||||
| showSuccess(t('更新成功')); | |||||
| loadRatios(); | |||||
| } else { | |||||
| showError(message); | |||||
| } | |||||
| }; | |||||
| const handleDelete = async (id) => { | |||||
| const res = await API.delete(`/api/user_channel_ratio/${id}`); | |||||
| const { success, message } = res.data; | |||||
| if (success) { | |||||
| showSuccess(t('删除成功')); | |||||
| loadRatios(); | |||||
| } else { | |||||
| showError(message); | |||||
| } | |||||
| }; | |||||
| const columns = [ | |||||
| { title: t('模型'), dataIndex: 'model_name', key: 'model_name' }, | |||||
| { | |||||
| title: t('渠道'), | |||||
| dataIndex: 'channel_id', | |||||
| key: 'channel_id', | |||||
| render: (channelId) => { | |||||
| const ch = channelOptions.find((c) => c.value === channelId); | |||||
| return ch ? ch.label : channelId; | |||||
| }, | |||||
| }, | |||||
| { | |||||
| title: t('倍率'), | |||||
| dataIndex: 'ratio', | |||||
| key: 'ratio', | |||||
| render: (text, record) => ( | |||||
| <InputNumber | |||||
| value={text} | |||||
| min={0.01} | |||||
| step={0.1} | |||||
| onBlur={(val) => { | |||||
| if (val !== text) handleUpdate(record.id, val); | |||||
| }} | |||||
| style={{ width: 100 }} | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| title: t('操作'), | |||||
| key: 'action', | |||||
| render: (_, record) => ( | |||||
| <Popconfirm | |||||
| title={t('确认删除?')} | |||||
| onConfirm={() => handleDelete(record.id)} | |||||
| > | |||||
| <Button type='danger' size='small'> | |||||
| {t('删除')} | |||||
| </Button> | |||||
| </Popconfirm> | |||||
| ), | |||||
| }, | |||||
| ]; | |||||
| return ( | |||||
| <> | |||||
| <div className='flex items-center justify-between mb-2'> | |||||
| <div className='flex items-center'> | |||||
| <Avatar size='small' color='orange' className='mr-2 shadow-md'> | |||||
| <IconStar size={16} /> | |||||
| </Avatar> | |||||
| <div> | |||||
| <Text className='text-lg font-medium'>{t('倍率设置')}</Text> | |||||
| <div className='text-xs text-gray-600'> | |||||
| {t( | |||||
| '为该用户设置特定模型+渠道的倍率乘数,未设置时默认为 1', | |||||
| )} | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <Button size='small' onClick={() => setAddModalVisible(true)}> | |||||
| {t('添加倍率')} | |||||
| </Button> | |||||
| </div> | |||||
| <Table | |||||
| columns={columns} | |||||
| dataSource={ratios} | |||||
| loading={loading} | |||||
| rowKey='id' | |||||
| size='small' | |||||
| pagination={false} | |||||
| empty={t('暂无倍率设置')} | |||||
| /> | |||||
| <Modal | |||||
| title={t('添加倍率')} | |||||
| visible={addModalVisible} | |||||
| onOk={() => formApiRef.current?.submitForm()} | |||||
| onCancel={() => setAddModalVisible(false)} | |||||
| > | |||||
| <Form | |||||
| getFormApi={(api) => (formApiRef.current = api)} | |||||
| onSubmit={handleAdd} | |||||
| > | |||||
| <Form.Select | |||||
| field='channel_id' | |||||
| label={t('渠道')} | |||||
| placeholder={t('请选择渠道')} | |||||
| optionList={channelOptions} | |||||
| filter | |||||
| showClear | |||||
| rules={[{ required: true, message: t('请选择渠道') }]} | |||||
| style={{ width: '100%' }} | |||||
| onChange={(val) => { | |||||
| formApiRef.current?.setValue('model_name', undefined); | |||||
| const selected = channelOptions.find((c) => c.value === val); | |||||
| if (selected?.models) { | |||||
| setModelOptions(selected.models.map((m) => ({ label: m, value: m }))); | |||||
| } else { | |||||
| setModelOptions([]); | |||||
| } | |||||
| }} | |||||
| /> | |||||
| <Form.Select | |||||
| field='model_name' | |||||
| label={t('模型')} | |||||
| placeholder={t('请选择模型')} | |||||
| optionList={modelOptions} | |||||
| filter | |||||
| showClear | |||||
| disabled={modelOptions.length === 0} | |||||
| rules={[{ required: true, message: t('请选择模型') }]} | |||||
| style={{ width: '100%' }} | |||||
| /> | |||||
| <Form.InputNumber | |||||
| field='ratio' | |||||
| label={t('倍率')} | |||||
| placeholder='1.0' | |||||
| initValue={1.0} | |||||
| min={0.01} | |||||
| step={0.1} | |||||
| rules={[{ required: true, message: t('请输入倍率') }]} | |||||
| style={{ width: '100%' }} | |||||
| /> | |||||
| </Form> | |||||
| </Modal> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default UserRatioSection; | |||||
| @@ -1184,8 +1184,7 @@ function getEffectiveRatio(groupRatio, user_group_ratio) { | |||||
| const useUserGroupRatio = isValidGroupRatio(user_group_ratio); | const useUserGroupRatio = isValidGroupRatio(user_group_ratio); | ||||
| const ratioLabel = useUserGroupRatio | const ratioLabel = useUserGroupRatio | ||||
| ? i18next.t('专属倍率') | ? i18next.t('专属倍率') | ||||
| // : i18next.t('分组倍率'); // 原文字,已改为"倍率" | |||||
| : i18next.t('倍率'); | |||||
| : i18next.t('分组倍率'); | |||||
| const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio; | const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio; | ||||
| return { | return { | ||||
| @@ -1316,7 +1315,6 @@ export function renderModelPrice( | |||||
| audioInputPrice = 0, | audioInputPrice = 0, | ||||
| imageGenerationCall = false, | imageGenerationCall = false, | ||||
| imageGenerationCallPrice = 0, | imageGenerationCallPrice = 0, | ||||
| userChannelRatio, | |||||
| ) { | ) { | ||||
| const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | ||||
| groupRatio, | groupRatio, | ||||
| @@ -1327,12 +1325,11 @@ export function renderModelPrice( | |||||
| // 获取货币配置 | // 获取货币配置 | ||||
| const { symbol, rate } = getCurrencyConfig(); | const { symbol, rate } = getCurrencyConfig(); | ||||
| const ucr = (userChannelRatio != null && userChannelRatio !== 1.0) ? userChannelRatio : null; | |||||
| if (modelPrice !== -1) { | if (modelPrice !== -1) { | ||||
| const displayPrice = (modelPrice * rate).toFixed(6); | const displayPrice = (modelPrice * rate).toFixed(6); | ||||
| const displayTotal = (modelPrice * groupRatio * (ucr || 1) * rate).toFixed(6); | |||||
| const ratioParts = `${ratioLabel}:${groupRatio}` + (ucr ? ` * 用户倍率:${ucr}` : ''); | |||||
| const displayTotal = (modelPrice * groupRatio * rate).toFixed(6); | |||||
| const ratioParts = `${ratioLabel}:${groupRatio}` ; | |||||
| return i18next.t( | return i18next.t( | ||||
| '模型价格:{{symbol}}{{price}} * {{ratioParts}} = {{symbol}}{{total}}', | '模型价格:{{symbol}}{{price}} * {{ratioParts}} = {{symbol}}{{total}}', | ||||
| { | { | ||||
| @@ -1370,9 +1367,6 @@ export function renderModelPrice( | |||||
| (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + | (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + | ||||
| imageGenerationCallPrice * groupRatio; | imageGenerationCallPrice * groupRatio; | ||||
| if (ucr) { | |||||
| price *= ucr; | |||||
| } | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -1501,14 +1495,14 @@ export function renderModelPrice( | |||||
| // 构建输出部分描述 | // 构建输出部分描述 | ||||
| const outputDesc = i18next.t( | const outputDesc = i18next.t( | ||||
| '输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}{{userRatio}}', | |||||
| '输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}', | |||||
| { | { | ||||
| completion: completionTokens, | completion: completionTokens, | ||||
| symbol: symbol, | symbol: symbol, | ||||
| compPrice: (completionRatioPrice * rate).toFixed(6), | compPrice: (completionRatioPrice * rate).toFixed(6), | ||||
| ratio: groupRatio, | ratio: groupRatio, | ||||
| ratioType: ratioLabel, | ratioType: ratioLabel, | ||||
| userRatio: ucr ? ` * 用户倍率 ${ucr}` : '', | |||||
| }, | }, | ||||
| ); | ); | ||||
| @@ -1583,7 +1577,6 @@ export function renderLogContent( | |||||
| webSearchCallCount = 0, | webSearchCallCount = 0, | ||||
| fileSearch = false, | fileSearch = false, | ||||
| fileSearchCallCount = 0, | fileSearchCallCount = 0, | ||||
| userChannelRatio, | |||||
| ) { | ) { | ||||
| const { | const { | ||||
| ratio, | ratio, | ||||
| @@ -1593,10 +1586,7 @@ export function renderLogContent( | |||||
| // 获取货币配置 | // 获取货币配置 | ||||
| const { symbol, rate } = getCurrencyConfig(); | const { symbol, rate } = getCurrencyConfig(); | ||||
| const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0) | |||||
| ? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio }) | |||||
| : ''; | |||||
| const userRatioSuffix = ''; | |||||
| let result; | let result; | ||||
| if (modelPrice !== -1) { | if (modelPrice !== -1) { | ||||
| @@ -1897,7 +1887,6 @@ export function renderClaudeModelPrice( | |||||
| cacheCreationRatio5m = 1.0, | cacheCreationRatio5m = 1.0, | ||||
| cacheCreationTokens1h = 0, | cacheCreationTokens1h = 0, | ||||
| cacheCreationRatio1h = 1.0, | cacheCreationRatio1h = 1.0, | ||||
| userChannelRatio, | |||||
| ) { | ) { | ||||
| const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | ||||
| groupRatio, | groupRatio, | ||||
| @@ -1955,7 +1944,7 @@ export function renderClaudeModelPrice( | |||||
| cacheCreationTokens5m * cacheCreationRatio5m + | cacheCreationTokens5m * cacheCreationRatio5m + | ||||
| cacheCreationTokens1h * cacheCreationRatio1h; | cacheCreationTokens1h * cacheCreationRatio1h; | ||||
| const userRatio = userChannelRatio ?? 1.0; | |||||
| const userRatio = 1.0; | |||||
| let price = | let price = | ||||
| ((effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio + | ((effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio + | ||||
| (completionTokens / 1000000) * completionRatioPrice * groupRatio) * | (completionTokens / 1000000) * completionRatioPrice * groupRatio) * | ||||
| @@ -2169,7 +2158,6 @@ export function renderClaudeLogContent( | |||||
| cacheCreationRatio5m = 1.0, | cacheCreationRatio5m = 1.0, | ||||
| cacheCreationTokens1h = 0, | cacheCreationTokens1h = 0, | ||||
| cacheCreationRatio1h = 1.0, | cacheCreationRatio1h = 1.0, | ||||
| userChannelRatio, | |||||
| ) { | ) { | ||||
| const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | ||||
| groupRatio, | groupRatio, | ||||
| @@ -2179,10 +2167,8 @@ export function renderClaudeLogContent( | |||||
| // 获取货币配置 | // 获取货币配置 | ||||
| const { symbol, rate } = getCurrencyConfig(); | const { symbol, rate } = getCurrencyConfig(); | ||||
| const userRatioSuffix = ''; | |||||
| const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0) | |||||
| ? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio }) | |||||
| : ''; | |||||
| if (modelPrice !== -1) { | if (modelPrice !== -1) { | ||||
| return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { | return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { | ||||
| @@ -431,7 +431,6 @@ export const useLogsData = () => { | |||||
| other.cache_creation_ratio_1h || | other.cache_creation_ratio_1h || | ||||
| other.cache_creation_ratio || | other.cache_creation_ratio || | ||||
| 1.0, | 1.0, | ||||
| other?.user_channel_ratio, | |||||
| ) | ) | ||||
| : renderLogContent( | : renderLogContent( | ||||
| other?.model_ratio, | other?.model_ratio, | ||||
| @@ -446,7 +445,6 @@ export const useLogsData = () => { | |||||
| other.web_search_call_count || 0, | other.web_search_call_count || 0, | ||||
| other.file_search || false, | other.file_search || false, | ||||
| other.file_search_call_count || 0, | other.file_search_call_count || 0, | ||||
| other?.user_channel_ratio, | |||||
| ), | ), | ||||
| }); | }); | ||||
| if (logs[i]?.content) { | if (logs[i]?.content) { | ||||
| @@ -522,7 +520,6 @@ export const useLogsData = () => { | |||||
| other.cache_creation_ratio_1h || | other.cache_creation_ratio_1h || | ||||
| other.cache_creation_ratio || | other.cache_creation_ratio || | ||||
| 1.0, | 1.0, | ||||
| other.user_channel_ratio, | |||||
| ); | ); | ||||
| } else { | } else { | ||||
| content = renderModelPrice( | content = renderModelPrice( | ||||
| @@ -549,7 +546,6 @@ export const useLogsData = () => { | |||||
| other?.audio_input_price || 0, | other?.audio_input_price || 0, | ||||
| other?.image_generation_call || false, | other?.image_generation_call || false, | ||||
| other?.image_generation_call_price || 0, | other?.image_generation_call_price || 0, | ||||
| other?.user_channel_ratio, | |||||
| ); | ); | ||||
| } | } | ||||
| expandDataLocal.push({ | expandDataLocal.push({ | ||||
| @@ -563,12 +559,6 @@ export const useLogsData = () => { | |||||
| value: other.reasoning_effort, | value: other.reasoning_effort, | ||||
| }); | }); | ||||
| } | } | ||||
| if (other?.user_channel_ratio) { | |||||
| expandDataLocal.push({ | |||||
| key: t('用户倍率'), | |||||
| value: other.user_channel_ratio, | |||||
| }); | |||||
| } | |||||
| } | } | ||||
| if (logs[i].type === 6) { | if (logs[i].type === 6) { | ||||
| if (other?.task_id) { | if (other?.task_id) { | ||||
| @@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. | |||||
| For commercial licensing, please contact support@quantumnous.com | For commercial licensing, please contact support@quantumnous.com | ||||
| */ | */ | ||||
| import React, { useEffect, useState, useRef, useCallback } from 'react'; | |||||
| import React, { useEffect, useState, useRef } from 'react'; | |||||
| import { | import { | ||||
| Table, | Table, | ||||
| Button, | Button, | ||||
| @@ -38,9 +38,7 @@ import { | |||||
| IconEdit, | IconEdit, | ||||
| } from '@douyinfe/semi-icons'; | } from '@douyinfe/semi-icons'; | ||||
| import { API, showError, showSuccess, getQuotaPerUnit } from '../../../helpers'; | import { API, showError, showSuccess, getQuotaPerUnit } from '../../../helpers'; | ||||
| import { channelPricingApi, pricingTagApi } from '../../../helpers/api'; | |||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import ChannelPricingView from './ChannelPricingView'; | |||||
| export default function ModelSettingsVisualEditor(props) { | export default function ModelSettingsVisualEditor(props) { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| @@ -58,12 +56,6 @@ export default function ModelSettingsVisualEditor(props) { | |||||
| const pageSize = 10; | const pageSize = 10; | ||||
| const quotaPerUnit = getQuotaPerUnit(); | const quotaPerUnit = getQuotaPerUnit(); | ||||
| // Channel pricing view mode | |||||
| const [viewMode, setViewMode] = useState('global'); // 'global' | 'channel' | |||||
| const [channels, setChannels] = useState([]); | |||||
| const [channelPricings, setChannelPricings] = useState([]); | |||||
| const [tags, setTags] = useState([]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| try { | try { | ||||
| const modelPrice = JSON.parse(props.options.ModelPrice || '{}'); | const modelPrice = JSON.parse(props.options.ModelPrice || '{}'); | ||||
| @@ -98,53 +90,6 @@ export default function ModelSettingsVisualEditor(props) { | |||||
| } | } | ||||
| }, [props.options]); | }, [props.options]); | ||||
| // Load channels for channel pricing view | |||||
| const loadChannels = useCallback(async () => { | |||||
| try { | |||||
| const res = await API.get('/api/channel/?page_size=100'); | |||||
| if (res.data.success) { | |||||
| // API returns {items: [...], total: ...}, extract items array | |||||
| const data = res.data.data; | |||||
| setChannels(Array.isArray(data) ? data : (data?.items || [])); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error('Failed to load channels', e); | |||||
| } | |||||
| }, []); | |||||
| // Load channel pricings | |||||
| const loadChannelPricings = useCallback(async () => { | |||||
| try { | |||||
| const res = await channelPricingApi.getWithTags(1, 1000); | |||||
| if (res.data.success) { | |||||
| setChannelPricings(res.data.data.items || []); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error('Failed to load channel pricings', e); | |||||
| } | |||||
| }, []); | |||||
| // Load tags | |||||
| const loadTags = useCallback(async () => { | |||||
| try { | |||||
| const res = await pricingTagApi.getAll(); | |||||
| if (res.data.success) { | |||||
| setTags(res.data.data || []); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error('Failed to load tags', e); | |||||
| } | |||||
| }, []); | |||||
| // Load channel data when switching to channel view | |||||
| useEffect(() => { | |||||
| if (viewMode === 'channel') { | |||||
| loadChannels(); | |||||
| loadChannelPricings(); | |||||
| loadTags(); | |||||
| } | |||||
| }, [viewMode, loadChannels, loadChannelPricings, loadTags]); | |||||
| // 首先声明分页相关的工具函数 | // 首先声明分页相关的工具函数 | ||||
| const getPagedData = (data, currentPage, pageSize) => { | const getPagedData = (data, currentPage, pageSize) => { | ||||
| const start = (currentPage - 1) * pageSize; | const start = (currentPage - 1) * pageSize; | ||||
| @@ -510,23 +455,9 @@ export default function ModelSettingsVisualEditor(props) { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| {/* View mode switch */} | |||||
| <div style={{ marginBottom: 16 }}> | |||||
| <RadioGroup | |||||
| value={viewMode} | |||||
| onChange={(e) => setViewMode(e.target.value)} | |||||
| type="button" | |||||
| > | |||||
| <Radio value="global">{t('全局定价')}</Radio> | |||||
| <Radio value="channel">{t('渠道定价')}</Radio> | |||||
| </RadioGroup> | |||||
| </div> | |||||
| {/* Global pricing view */} | |||||
| {viewMode === 'global' && ( | |||||
| <Space vertical align='start' style={{ width: '100%' }}> | |||||
| <Space className='mt-2'> | |||||
| <Button | |||||
| <Space vertical align='start' style={{ width: '100%' }}> | |||||
| <Space className='mt-2'> | |||||
| <Button | |||||
| icon={<IconPlus />} | icon={<IconPlus />} | ||||
| onClick={() => { | onClick={() => { | ||||
| resetModalState(); | resetModalState(); | ||||
| @@ -572,17 +503,6 @@ export default function ModelSettingsVisualEditor(props) { | |||||
| }} | }} | ||||
| /> | /> | ||||
| </Space> | </Space> | ||||
| )} | |||||
| {/* Channel pricing view */} | |||||
| {viewMode === 'channel' && ( | |||||
| <ChannelPricingView | |||||
| channels={channels} | |||||
| channelPricings={channelPricings} | |||||
| tags={tags} | |||||
| onRefresh={loadChannelPricings} | |||||
| /> | |||||
| )} | |||||
| <Modal | <Modal | ||||
| title={isEditMode ? t('编辑模型') : t('添加模型')} | title={isEditMode ? t('编辑模型') : t('添加模型')} | ||||