Skip to content

Feature: 订阅套餐分组限制(Subscription Plan Group Restriction) #3388

@ibadoo

Description

@ibadoo

提交前必读(请勿删除本节)

您当前的 newapi 版本

v1.0.0+(基于最新 main 分支代码)

提交确认

  • 我已确认目前没有类似 issue
  • 我已完整查看过文档 https://docs.newapi.ai/ 和项目 README,已确定现有版本无法满足需求
  • 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
  • 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭

功能描述

为订阅套餐增加分组限制功能,允许管理员创建仅适用于特定用户分组的订阅套餐。

核心设计:

  • SubscriptionPlanUserSubscription 新增 AllowedGroups 字段(逗号分隔字符串,空值 = 适用所有分组)
  • 计费时根据请求的 UsingGroup 匹配订阅,只消费匹配的订阅额度
  • 完全向后兼容:现有订阅 AllowedGroups 为空,行为不变

应用场景

当前订阅系统的配额是全站共用的,无法区分不同分组。这在以下场景中存在问题:

  1. 多产品线运营:运营方同时提供多个 AI 服务(如 Codex 代码助手、Claude 对话服务),希望为每个产品线创建独立的订阅套餐,实现配额隔离和差异化定价。

  2. 企业客户管理:企业客户购买特定模型的订阅(如仅购买 GPT-4 访问权限),不希望额度被用于其他模型分组。

  3. 成本控制:不同分组的上游成本差异大(如 o1 模型成本远高于 GPT-3.5),需要独立定价和额度管理。

期望行为示例

  • 购买「Codex 套餐」→ 增加 100 刀额度,仅供 codex 分组使用,不能用于 claude 分组
  • 购买「Claude 套餐」→ 增加 50 刀额度,仅供 claude 分组使用
  • 购买「通用套餐」→ 额度适用于所有分组(向后兼容现有行为)

详细实现方案

1. 数据模型(model/subscription.go

SubscriptionPlan 新增字段(第 169 行后):

AllowedGroups string `json:"allowed_groups" gorm:"type:text;default:''"`

UserSubscription 新增字段(第 251 行后):

AllowedGroups string `json:"allowed_groups" gorm:"type:text;default:''"`

新增辅助函数

// isGroupAllowed checks if a group is allowed by the allowedGroups setting.
// Empty allowedGroups means all groups are allowed.
func isGroupAllowed(allowedGroups string, group string) bool {
    if strings.TrimSpace(allowedGroups) == "" {
        return true
    }
    groups := strings.Split(allowedGroups, ",")
    for _, g := range groups {
        if strings.TrimSpace(g) == group {
            return true
        }
    }
    return false
}

2. 核心逻辑修改

CreateUserSubscriptionFromPlanTx(第 485 行):

sub := &UserSubscription{
    // ... 现有字段 ...
    AllowedGroups: plan.AllowedGroups,  // 新增:拷贝套餐的分组限制
}

PreConsumeUserSubscription(第 956 行):

  • 函数签名修改:func PreConsumeUserSubscription(requestId string, userId int, modelName string, quotaType int, amount int64, usingGroup string) (*SubscriptionPreConsumeResult, error)
  • 在候选订阅遍历循环(第 1002 行)中加入分组过滤:
for _, candidate := range subs {
    sub := candidate
    // 新增:检查分组是否匹配
    if !isGroupAllowed(sub.AllowedGroups, usingGroup) {
        continue
    }
    // ... 现有逻辑 ...
}

新增 HasActiveUserSubscriptionForGroup

// HasActiveUserSubscriptionForGroup checks if user has active subscription for specific group.
func HasActiveUserSubscriptionForGroup(userId int, usingGroup string) (bool, error) {
    if userId <= 0 {
        return false, errors.New("invalid userId")
    }
    now := common.GetTimestamp()
    var subs []UserSubscription
    if err := DB.Model(&UserSubscription{}).
        Select("id, allowed_groups").
        Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now).
        Find(&subs).Error; err != nil {
        return false, err
    }
    for _, sub := range subs {
        if isGroupAllowed(sub.AllowedGroups, usingGroup) {
            return true, nil
        }
    }
    return false, nil
}

3. 数据库迁移(model/main.go

SQLiteensureSubscriptionPlanTableSQLite 函数,第 381 行):

  • CREATE TABLE 语句中添加:allowed_groups TEXT DEFAULT ''
  • required columns 数组添加:"allowed_groups"

MySQL/PostgreSQL:GORM AutoMigrate 自动处理新增字段。

4. 计费层(service/

funding_source.go(第 70 行):

type SubscriptionFunding struct {
    // ... 现有字段 ...
    usingGroup string  // 新增
}

PreConsume 方法(第 86 行):

res, err := model.PreConsumeUserSubscription(
    s.requestId,
    s.userId,
    s.modelName,
    0,
    s.amount,
    s.usingGroup,  // 新增参数
)

billing_session.go(第 255 行):

trySubscription 闭包(第 292 行):

session := &BillingSession{
    relayInfo: relayInfo,
    funding: &SubscriptionFunding{
        requestId: relayInfo.RequestId,
        userId:    relayInfo.UserId,
        modelName: relayInfo.OriginModelName,
        amount:    subConsume,
        usingGroup: relayInfo.UsingGroup,  // 新增
    },
}

subscription_first 分支(第 328 行):

hasSub, subCheckErr := model.HasActiveUserSubscriptionForGroup(relayInfo.UserId, relayInfo.UsingGroup)

5. 管理接口(controller/subscription.go

AdminCreateSubscriptionPlan(第 110 行)和 AdminUpdateSubscriptionPlan(第 168 行):

添加分组验证(在 UpgradeGroup 验证后):

// 验证 AllowedGroups
req.Plan.AllowedGroups = strings.TrimSpace(req.Plan.AllowedGroups)
if req.Plan.AllowedGroups != "" {
    groups := strings.Split(req.Plan.AllowedGroups, ",")
    groupRatios := ratio_setting.GetGroupRatioCopy()
    for _, g := range groups {
        g = strings.TrimSpace(g)
        if g != "" {
            if _, ok := groupRatios[g]; !ok {
                common.ApiErrorMsg(c, fmt.Sprintf("分组 %s 不存在", g))
                return
            }
        }
    }
}

Update map 中添加(第 223 行):

updateMap := map[string]interface{}{
    // ... 现有字段 ...
    "allowed_groups": req.Plan.AllowedGroups,  // 新增
}

6. 前端

管理后台表单web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx):

  • 添加分组多选 Form.Select(复用已有的 groupOptions)
  • getInitValuesbuildFormValues 添加 allowed_groups 处理(数组 ↔ 逗号分隔字符串转换)

管理后台表格web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx):

  • 新增「适用分组」列
  • renderPlanTitle popover 中添加适用分组展示

用户侧展示web/src/components/topup/SubscriptionPlansCard.jsx):

  • 套餐权益列表中添加适用分组信息展示

i18n

  • en.json 添加:"适用分组" → "Allowed Groups","所有分组" → "All Groups"
  • 运行 bun run i18n:sync 同步其他语言

向后兼容性

场景 行为
现有订阅(AllowedGroups 为空) 适用所有分组,行为不变
新订阅指定分组 仅匹配的分组请求消费该订阅
无匹配订阅时 按现有逻辑 fallback 到钱包
API 响应 新字段默认空字符串,前端兼容

涉及文件清单

后端

  • model/subscription.go
  • model/main.go
  • service/funding_source.go
  • service/billing_session.go
  • controller/subscription.go

前端

  • web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx
  • web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx
  • web/src/components/topup/SubscriptionPlansCard.jsx
  • web/src/i18n/locales/en.json(及其他语言文件)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions