diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 5be66768..4725c3d1 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -1,6 +1,9 @@ package admin import ( + "bytes" + "encoding/json" + "fmt" "strconv" "strings" @@ -16,6 +19,55 @@ type GroupHandler struct { adminService service.AdminService } +type optionalLimitField struct { + set bool + value *float64 +} + +func (f *optionalLimitField) UnmarshalJSON(data []byte) error { + f.set = true + + trimmed := bytes.TrimSpace(data) + if bytes.Equal(trimmed, []byte("null")) { + f.value = nil + return nil + } + + var number float64 + if err := json.Unmarshal(trimmed, &number); err == nil { + f.value = &number + return nil + } + + var text string + if err := json.Unmarshal(trimmed, &text); err == nil { + text = strings.TrimSpace(text) + if text == "" { + f.value = nil + return nil + } + number, err = strconv.ParseFloat(text, 64) + if err != nil { + return fmt.Errorf("invalid numeric limit value %q: %w", text, err) + } + f.value = &number + return nil + } + + return fmt.Errorf("invalid limit value: %s", string(trimmed)) +} + +func (f optionalLimitField) ToServiceInput() *float64 { + if !f.set { + return nil + } + if f.value != nil { + return f.value + } + zero := 0.0 + return &zero +} + // NewGroupHandler creates a new admin group handler func NewGroupHandler(adminService service.AdminService) *GroupHandler { return &GroupHandler{ @@ -31,9 +83,9 @@ type CreateGroupRequest struct { RateMultiplier float64 `json:"rate_multiplier"` IsExclusive bool `json:"is_exclusive"` SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` - DailyLimitUSD *float64 `json:"daily_limit_usd"` - WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` - MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` + DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` + WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` + MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` @@ -69,9 +121,9 @@ type UpdateGroupRequest struct { IsExclusive *bool `json:"is_exclusive"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` - DailyLimitUSD *float64 `json:"daily_limit_usd"` - WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` - MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` + DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` + WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` + MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` @@ -191,9 +243,9 @@ func (h *GroupHandler) Create(c *gin.Context) { RateMultiplier: req.RateMultiplier, IsExclusive: req.IsExclusive, SubscriptionType: req.SubscriptionType, - DailyLimitUSD: req.DailyLimitUSD, - WeeklyLimitUSD: req.WeeklyLimitUSD, - MonthlyLimitUSD: req.MonthlyLimitUSD, + DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(), + WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(), + MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(), ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, @@ -244,9 +296,9 @@ func (h *GroupHandler) Update(c *gin.Context) { IsExclusive: req.IsExclusive, Status: req.Status, SubscriptionType: req.SubscriptionType, - DailyLimitUSD: req.DailyLimitUSD, - WeeklyLimitUSD: req.WeeklyLimitUSD, - MonthlyLimitUSD: req.MonthlyLimitUSD, + DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(), + WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(), + MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(), ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index a78762d6..71fa7a20 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -2368,6 +2368,23 @@ const closeCreateModal = () => { createModelRoutingRules.value = [] } +const normalizeOptionalLimit = (value: number | string | null | undefined): number | null => { + if (value === null || value === undefined) { + return null + } + + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) { + return null + } + const parsed = Number(trimmed) + return Number.isFinite(parsed) && parsed > 0 ? parsed : null + } + + return Number.isFinite(value) && value > 0 ? value : null +} + const handleCreateGroup = async () => { if (!createForm.name.trim()) { appStore.showError(t('admin.groups.nameRequired')) @@ -2379,6 +2396,9 @@ const handleCreateGroup = async () => { const { sora_storage_quota_gb: createQuotaGb, ...createRest } = createForm const requestData = { ...createRest, + daily_limit_usd: normalizeOptionalLimit(createForm.daily_limit_usd as number | string | null), + weekly_limit_usd: normalizeOptionalLimit(createForm.weekly_limit_usd as number | string | null), + monthly_limit_usd: normalizeOptionalLimit(createForm.monthly_limit_usd as number | string | null), sora_storage_quota_bytes: createQuotaGb ? Math.round(createQuotaGb * 1024 * 1024 * 1024) : 0, model_routing: convertRoutingRulesToApiFormat(createModelRoutingRules.value) } @@ -2457,6 +2477,9 @@ const handleUpdateGroup = async () => { const { sora_storage_quota_gb: editQuotaGb, ...editRest } = editForm const payload = { ...editRest, + daily_limit_usd: normalizeOptionalLimit(editForm.daily_limit_usd as number | string | null), + weekly_limit_usd: normalizeOptionalLimit(editForm.weekly_limit_usd as number | string | null), + monthly_limit_usd: normalizeOptionalLimit(editForm.monthly_limit_usd as number | string | null), sora_storage_quota_bytes: editQuotaGb ? Math.round(editQuotaGb * 1024 * 1024 * 1024) : 0, fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id, fallback_group_id_on_invalid_request: