diff --git a/heicode/controller/agent_control_plane.go b/heicode/controller/agent_control_plane.go index c1942e4f..d5b22737 100644 --- a/heicode/controller/agent_control_plane.go +++ b/heicode/controller/agent_control_plane.go @@ -1166,6 +1166,12 @@ func createAgentDeployment(c *gin.Context, enforceUserScope bool) { } func AgentCreateUserSwarm(c *gin.Context) { + // 订阅套餐 gate:蜂群按套餐开通(管理员在套餐编辑里逐个设 SwarmEnabled,不硬编码 tier)。 + // 普通用户须有任一活跃套餐开通蜂群;管理员(role>=admin)绕过,便于测试/运维。 + if c.GetInt("role") < common.RoleAdminUser && !model.GetUserSwarmEnabled(c.GetInt("id")) { + agentError(c, "POLICY_REJECTED", "当前订阅套餐未开通蜂群(swarm);请升级套餐或联系管理员") + return + } record, ok := createAgentDeploymentRecord(c, true, agentRuntimeModeSwarm) if !ok { return diff --git a/heicode/controller/agent_control_plane_test.go b/heicode/controller/agent_control_plane_test.go index 41774e54..2b13dacf 100644 --- a/heicode/controller/agent_control_plane_test.go +++ b/heicode/controller/agent_control_plane_test.go @@ -1008,6 +1008,9 @@ func TestAgentUserSwarmsAdapterCreatesScopedDeployment(t *testing.T) { recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Set("id", 7) + // 蜂群订阅 gate(PR#70):普通用户须有开通蜂群的活跃套餐;admin 绕过(便于测试/运维)。 + // 本用例聚焦 adapter 的用户作用域,非 gate 本身(gate 见 TestGetUserSwarmEnabled),走 admin 旁路。 + ctx.Set("role", common.RoleAdminUser) ctx.Set("group", "development") ctx.Request = httptest.NewRequest(http.MethodPost, "/api/swarms", strings.NewReader(string(body))) ctx.Request.Header.Set("Content-Type", "application/json") diff --git a/heicode/controller/subscription.go b/heicode/controller/subscription.go index 0735f0df..6dbba52f 100644 --- a/heicode/controller/subscription.go +++ b/heicode/controller/subscription.go @@ -4,10 +4,10 @@ import ( "strconv" "strings" + "github.com/gin-gonic/gin" "github.com/heicode/manager/common" "github.com/heicode/manager/model" "github.com/heicode/manager/setting/ratio_setting" - "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -244,6 +244,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { "creem_product_id": req.Plan.CreemProductId, "max_purchase_per_user": req.Plan.MaxPurchasePerUser, "max_agents": req.Plan.MaxAgents, + "swarm_enabled": req.Plan.SwarmEnabled, "total_amount": req.Plan.TotalAmount, "upgrade_group": req.Plan.UpgradeGroup, "quota_reset_period": req.Plan.QuotaResetPeriod, diff --git a/heicode/model/subscription.go b/heicode/model/subscription.go index f8480ddc..451cd5c1 100644 --- a/heicode/model/subscription.go +++ b/heicode/model/subscription.go @@ -171,6 +171,12 @@ type SubscriptionPlan struct { // expressed: admins set each plan's cap; the code does not hard-code tiers. MaxAgents int `json:"max_agents" gorm:"type:int;default:0"` + // Whether users on this plan may use the multi-agent swarm (蜂群). + // Default false: swarm access is opt-in per plan; admins toggle it in the + // plan editor. Enforced in AgentCreateUserSwarm via GetUserSwarmEnabled. + // No hard-coded tiers — admins decide which plans get swarm. + SwarmEnabled bool `json:"swarm_enabled" gorm:"default:false"` + // Upgrade user group after purchase (empty = no change) UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"` @@ -732,6 +738,32 @@ func GetUserMaxAgents(userId int, defaultMax int) int { return defaultMax } +// GetUserSwarmEnabled reports whether the user has any active subscription plan +// with SwarmEnabled=true. Mirrors GetUserMaxAgents (#8, tier-aware): no +// hard-coded tiers — swarm access is per-plan, set by admins. No active plan / +// DB unavailable => false (swarm is opt-in per plan). +func GetUserSwarmEnabled(userId int) bool { + if userId <= 0 || DB == nil { + return false + } + now := common.GetTimestamp() + var subs []UserSubscription + if err := DB.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now). + Find(&subs).Error; err != nil || len(subs) == 0 { + return false + } + for _, s := range subs { + plan, err := GetSubscriptionPlanById(s.PlanId) + if err != nil || plan == nil { + continue + } + if plan.SwarmEnabled { + return true + } + } + return false +} + // GetAllUserSubscriptions returns all subscriptions (active and expired) for a user. func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) { if userId <= 0 { diff --git a/heicode/model/subscription_swarm_enabled_test.go b/heicode/model/subscription_swarm_enabled_test.go new file mode 100644 index 00000000..9040be9b --- /dev/null +++ b/heicode/model/subscription_swarm_enabled_test.go @@ -0,0 +1,47 @@ +package model + +import ( + "testing" + + "github.com/heicode/manager/common" + "github.com/stretchr/testify/require" +) + +// GetUserSwarmEnabled gates swarm access per subscription plan (admin-set, +// opt-in): true iff the user has any ACTIVE plan with SwarmEnabled=true. +func TestGetUserSwarmEnabled(t *testing.T) { + now := common.GetTimestamp() + future := now + 100000 + + onPlan := SubscriptionPlan{Title: "swarm-on", SwarmEnabled: true} + require.NoError(t, DB.Create(&onPlan).Error) + offPlan := SubscriptionPlan{Title: "swarm-off", SwarmEnabled: false} + require.NoError(t, DB.Create(&offPlan).Error) + + mkSub := func(uid, planId int, end int64) { + require.NoError(t, DB.Create(&UserSubscription{UserId: uid, PlanId: planId, Status: "active", EndTime: end}).Error) + } + + // 1. no subscription -> false (opt-in default) + require.False(t, GetUserSwarmEnabled(991001)) + + // 2. active plan with swarm on -> true + mkSub(991002, onPlan.Id, future) + require.True(t, GetUserSwarmEnabled(991002)) + + // 3. active plan with swarm off -> false + mkSub(991003, offPlan.Id, future) + require.False(t, GetUserSwarmEnabled(991003)) + + // 4. expired swarm-on plan -> false (not active) + mkSub(991004, onPlan.Id, now-100) + require.False(t, GetUserSwarmEnabled(991004)) + + // 5. multiple active plans, one on -> true + mkSub(991005, offPlan.Id, future) + mkSub(991005, onPlan.Id, future) + require.True(t, GetUserSwarmEnabled(991005)) + + // guard: invalid user -> false + require.False(t, GetUserSwarmEnabled(0)) +} diff --git a/heicode/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx b/heicode/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx index 97a2af4d..cac630cf 100644 --- a/heicode/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx +++ b/heicode/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx @@ -350,6 +350,24 @@ export function SubscriptionsMutateDrawer({ )} /> + + ( + + + + + + {t('Swarm Access')} + + + )} + /> diff --git a/heicode/web/default/src/features/subscriptions/lib/plan-form.ts b/heicode/web/default/src/features/subscriptions/lib/plan-form.ts index 328faf3d..668dc2df 100644 --- a/heicode/web/default/src/features/subscriptions/lib/plan-form.ts +++ b/heicode/web/default/src/features/subscriptions/lib/plan-form.ts @@ -22,6 +22,7 @@ export function getPlanFormSchema(t: TFunction) { sort_order: z.coerce.number(), max_purchase_per_user: z.coerce.number().min(0), max_agents: z.coerce.number().min(0), + swarm_enabled: z.boolean(), total_amount: z.coerce.number().min(0), upgrade_group: z.string().optional(), stripe_price_id: z.string().optional(), @@ -44,6 +45,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = { sort_order: 0, max_purchase_per_user: 0, max_agents: 0, + swarm_enabled: false, total_amount: 0, upgrade_group: '', stripe_price_id: '', @@ -64,6 +66,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues { sort_order: Number(plan.sort_order || 0), max_purchase_per_user: Number(plan.max_purchase_per_user || 0), max_agents: Number(plan.max_agents || 0), + swarm_enabled: plan.swarm_enabled === true, total_amount: Number(plan.total_amount || 0), upgrade_group: plan.upgrade_group || '', stripe_price_id: plan.stripe_price_id || '', diff --git a/heicode/web/default/src/features/subscriptions/types.ts b/heicode/web/default/src/features/subscriptions/types.ts index 1fb420e3..79c93522 100644 --- a/heicode/web/default/src/features/subscriptions/types.ts +++ b/heicode/web/default/src/features/subscriptions/types.ts @@ -19,6 +19,7 @@ export const subscriptionPlanSchema = z.object({ sort_order: z.number(), max_purchase_per_user: z.number(), max_agents: z.number(), + swarm_enabled: z.boolean().optional().default(false), total_amount: z.number(), upgrade_group: z.string().optional(), stripe_price_id: z.string().optional(), diff --git a/heicode/web/default/src/i18n/locales/zh.json b/heicode/web/default/src/i18n/locales/zh.json index 1654ab34..2921ceaa 100644 --- a/heicode/web/default/src/i18n/locales/zh.json +++ b/heicode/web/default/src/i18n/locales/zh.json @@ -16,6 +16,7 @@ "0 uses the global default cap": "0 表示使用全局默认上限", "1 Day": "1 天", "Agent Deploy Limit": "Agent 部署上限", + "Swarm Access": "蜂群使用权", "1 day ago": "1 天前", "1 Hour": "1 小时", "1 hour ago": "1 小时前",